diff --git a/screens/payload/full_game_payload.py b/screens/payload/full_game_payload.py index c42aa2fa..503f8ae6 100644 --- a/screens/payload/full_game_payload.py +++ b/screens/payload/full_game_payload.py @@ -96,7 +96,23 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED): for d in definitions: full = db.get_definition(slug, d["id"]) rows = db.list_rows(slug, full, player_id) - cols = {f["name"]: db.slugify(f["name"]).replace("-", "_") for f in full["fields"]} + # Un champ "relation" vit dans une colonne SQL "_id", jamais + # sous son nom "propre" (voir _field_column dans + # filter_repeater_rows.py, la même règle) — sans ce cas particulier, + # un champ relation lisait toujours une colonne inexistante et + # renvoyait systématiquement None ici. Inoffensif EN LIGNE (cette + # snapshot n'est jamais utilisée pour un filtre/une Donnée liée : ces + # derniers requêtent la base fraîche via filter_repeater_rows.py) — + # mais fatal pour le port hors ligne (export Web/SCORM), qui n'a + # QUE cette snapshot, aucune base à requêter (bug signalé par un + # utilisateur : un filtre sur un champ relation ne matchait jamais + # rien une fois exporté, laissant "{{champ}}" affiché tel quel). + cols = {} + for f in full["fields"]: + col = db.slugify(f["name"]).replace("-", "_") + if f["type"] == "relation": + col += "_id" + cols[f["name"]] = col data[str(d["id"])] = [ {**{fname: r.get(col) for fname, col in cols.items()}, "id": r["id"]} for r in rows diff --git a/static/js/play/offline/__tests__/filter-repeater-rows.test.js b/static/js/play/offline/__tests__/filter-repeater-rows.test.js index 077c1ee8..bd030233 100644 --- a/static/js/play/offline/__tests__/filter-repeater-rows.test.js +++ b/static/js/play/offline/__tests__/filter-repeater-rows.test.js @@ -5,8 +5,8 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const { - forgeDecodeClauses, forgeResolveVariablePath, forgeFieldColumn, forgeSlugifyFieldName, - forgeResolveFilterValue, forgeFilterRowsByClauses, forgeFilterRepeaterRows, + forgeDecodeClauses, forgeResolveVariablePath, + forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows, } = require('../filter-repeater-rows.js'); function baseGameData() { @@ -17,11 +17,15 @@ function baseGameData() { }, definition_names: { '1': 'Progression', '2': 'Ennemis' }, fields_meta: { + // "boss" (relation) : gameData.data (voir full_game_payload.py) est + // déjà indexé par le nom D'AFFICHAGE du champ ("boss"), JAMAIS par sa + // colonne SQL réelle ("boss_id") — contrairement à côté serveur + // (filter_repeater_rows.py::_field_column), qui lit la base fraîche. '1': [{ name: 'niveau_actuel', type: 'nombre_entier' }, { name: 'boss', type: 'relation' }], '2': [{ name: 'nom', type: 'texte' }, { name: 'pv', type: 'nombre_entier' }], }, data: { - '1': [{ id: 1, niveau_actuel: 4, boss_id: 7 }], + '1': [{ id: 1, niveau_actuel: 4, boss: 7 }], '2': [ { id: 1, nom: 'Slime', pv: 10 }, { id: 2, nom: 'Dragon', pv: 100 }, @@ -30,15 +34,21 @@ function baseGameData() { }; } -test('forgeSlugifyFieldName correspond à db.slugify(...).replace("-","_")', () => { - assert.equal(forgeSlugifyFieldName('Nom Complet'), 'nom_complet'); - assert.equal(forgeSlugifyFieldName('Âge (ans)'), 'ge_ans'); +// Régression signalée par un utilisateur : un filtre/une Donnée liée sur +// un champ "relation" ne matchait jamais rien une fois exporté (le port +// hors ligne lisait à tort row[nom_slugifié + "_id"], une clé qui n'existe +// pas dans gameData.data), laissant "{{champ}}" affiché tel quel au lieu +// de la valeur résolue. +test('forgeClauseMatches — un champ "relation" se lit directement par son nom (pas de colonne SQL à reconstruire)', () => { + const gd = baseGameData(); + const row = gd.data['1'][0]; + const clause = { champ: 'boss', operateur: 'egal', valeur: '7' }; + assert.equal(forgeClauseMatches(gd, '1', row, clause), true); }); -test('forgeFieldColumn ajoute _id pour un champ relation', () => { - const fields = [{ name: 'boss', type: 'relation' }, { name: 'nom', type: 'texte' }]; - assert.equal(forgeFieldColumn(fields, 'boss'), 'boss_id'); - assert.equal(forgeFieldColumn(fields, 'nom'), 'nom'); +test('forgeResolveFilterValue — {{Objet.champ}} sur un champ "relation"', () => { + const gd = baseGameData(); + assert.equal(forgeResolveFilterValue(gd, '{{Progression.boss}}'), 7); }); test('forgeResolveVariablePath — chemin objet/tableau, chemin vide, JSON invalide', () => { diff --git a/static/js/play/offline/filter-repeater-rows.js b/static/js/play/offline/filter-repeater-rows.js index 2db4f129..33d90d32 100644 --- a/static/js/play/offline/filter-repeater-rows.js +++ b/static/js/play/offline/filter-repeater-rows.js @@ -65,22 +65,19 @@ function forgeResolveVariablePath(rawValue, path) { return current; } -// Port de _field_column — un champ "relation" vit dans une colonne -// "_id" (voir screens/data_actions/apply_data_action.py) ; le -// payload hors ligne (gameData.fields_meta) indexe déjà les lignes par le -// nom SLUGIFIÉ du champ (voir full_game_payload.py), jamais par son nom -// affiché — slugifyLike() reproduit db.slugify()+replace("-","_"). -function forgeSlugifyFieldName(name) { - return String(name).trim().toLowerCase() - .replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-/g, "_") || "jeu"; -} - -function forgeFieldColumn(fieldsMetaForDef, fieldName) { - var col = forgeSlugifyFieldName(fieldName); - var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; }); - if (field && field.type === "relation") col += "_id"; - return col; -} +// PAS d'équivalent JS de _field_column (screens/rendering/ +// filter_repeater_rows.py) : côté serveur, cette fonction traduit un nom +// de champ vers la VRAIE colonne SQL ("_id" pour une relation), +// nécessaire puisque _resolve_filter_value/_clause_matches lisent la base +// FRAÎCHE à chaque appel (SELECT * -> colonnes réelles). Hors ligne, il +// n'y a AUCUNE base à requêter : gameData.data (voir full_game_payload.py, +// `{fname: r.get(col) ...}`) est déjà ré-indexé par le nom D'AFFICHAGE du +// champ, PAS par sa colonne SQL — donc `row[fieldName]` suffit partout ici +// (une ancienne version de ce fichier appelait à tort une slugification +// ici, un bug jamais visible en ligne mais qui rendait tout champ +// "relation" illisible une fois exporté : le filtre/la Donnée liée +// correspondants ne matchaient jamais rien, laissant "{{champ}}" affiché +// tel quel — bug signalé par un utilisateur). // Port de _resolve_filter_value — {{Objet.champ}} (ligne la plus récente // d'un AUTRE objet, convention "1 objet = 1 ligne = état de partie") ou @@ -106,8 +103,7 @@ function forgeResolveFilterValue(gameData, rawValue) { if (names[defId] === objectName) { var rows = gameData.data && gameData.data[defId]; if (rows && rows.length) { - var col = forgeFieldColumn(gameData.fields_meta && gameData.fields_meta[defId], fieldName); - return rows[0][col]; + return rows[0][fieldName]; } return null; } @@ -124,8 +120,7 @@ function forgeClauseMatches(gameData, definitionId, row, clause) { var fieldsMetaForDef = gameData.fields_meta && gameData.fields_meta[definitionId]; var field = (fieldsMetaForDef || []).find(function (f) { return f.name === fieldName; }); var fieldType = field ? field.type : "texte"; - var col = forgeFieldColumn(fieldsMetaForDef, fieldName); - return compareValues(row[col], operator, expected, fieldType); + return compareValues(row[fieldName], operator, expected, fieldType); } // Port de filter_rows_by_clauses. @@ -151,7 +146,7 @@ function forgeFilterRepeaterRows(gameData, definitionId, rows, attrs) { if (typeof module !== 'undefined' && module.exports) { module.exports = { - forgeDecodeClauses, forgeResolveVariablePath, forgeFieldColumn, forgeSlugifyFieldName, + forgeDecodeClauses, forgeResolveVariablePath, forgeResolveFilterValue, forgeClauseMatches, forgeFilterRowsByClauses, forgeFilterRepeaterRows, }; global.compareValues = global.compareValues || require('../conditions.js').compareValues; diff --git a/static/js/play/offline/render-element.js b/static/js/play/offline/render-element.js index 060f76a5..7a6e9a4a 100644 --- a/static/js/play/offline/render-element.js +++ b/static/js/play/offline/render-element.js @@ -173,7 +173,10 @@ function forgeResolveBoundRowCtx(gameData, attrs) { var row = rows[0]; var ctx = {}; fieldsMetaForDef.forEach(function (f) { - var v = row[forgeFieldColumn(fieldsMetaForDef, f.name)]; + // row[f.name] : gameData.data est déjà indexé par le nom D'AFFICHAGE + // du champ (voir full_game_payload.py), jamais par sa colonne SQL — + // voir filter-repeater-rows.js pour le détail de ce piège. + var v = row[f.name]; ctx[f.name] = (v === null || v === undefined || v === "") ? "" : String(v); }); return ctx; @@ -319,7 +322,6 @@ if (typeof module !== 'undefined' && module.exports) { var filterMod = require('./filter-repeater-rows.js'); global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue; global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath; - global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn; global.forgeDecodeClauses = global.forgeDecodeClauses || filterMod.forgeDecodeClauses; global.forgeFilterRowsByClauses = global.forgeFilterRowsByClauses || filterMod.forgeFilterRowsByClauses; global.compareValues = global.compareValues || require('../conditions.js').compareValues; diff --git a/static/js/play/offline/render-repeater.js b/static/js/play/offline/render-repeater.js index 18de0bb6..0db08a9d 100644 --- a/static/js/play/offline/render-repeater.js +++ b/static/js/play/offline/render-repeater.js @@ -25,8 +25,6 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir var rows = gameData.data[definitionId] || []; rows = forgeFilterRepeaterRows(gameData, definitionId, rows, attrsRaw); - var fieldCols = {}; - fieldsMetaForDef.forEach(function (f) { fieldCols[f.name] = forgeFieldColumn(fieldsMetaForDef, f.name); }); var templateTree = null; if (templateTypeId) { @@ -35,9 +33,13 @@ function forgeRenderRepeater(el, meta, gameData, childrenMap, ctx, parentFlexDir var items = rows.map(function (row) { var rowCtx = Object.assign({}, ctx || {}); - Object.keys(fieldCols).forEach(function (fname) { - var v = row[fieldCols[fname]]; - rowCtx[fname] = v === null || v === undefined ? "" : String(v); + // gameData.data est déjà indexé par le nom D'AFFICHAGE du champ (voir + // full_game_payload.py), jamais par sa colonne SQL — row[f.name] lit + // donc directement la bonne valeur, y compris pour un champ "relation" + // (voir filter-repeater-rows.js pour le détail de ce piège). + fieldsMetaForDef.forEach(function (f) { + var v = row[f.name]; + rowCtx[f.name] = v === null || v === undefined ? "" : String(v); }); var inner; if (templateTree) { @@ -73,5 +75,4 @@ if (typeof module !== 'undefined' && module.exports) { }); var filterMod = require('./filter-repeater-rows.js'); global.forgeFilterRepeaterRows = global.forgeFilterRepeaterRows || filterMod.forgeFilterRepeaterRows; - global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn; } diff --git a/static/js/play/offline/visibility-condition.js b/static/js/play/offline/visibility-condition.js index 279cd311..158ba3bc 100644 --- a/static/js/play/offline/visibility-condition.js +++ b/static/js/play/offline/visibility-condition.js @@ -1,8 +1,8 @@ // Export Web/SCORM (voir le plan "port complet du runtime jouable côté // navigateur") — port de screens/rendering/visibility_condition.py // (is_element_visible). Réutilise forgeResolveFilterValue/ -// forgeResolveVariablePath/forgeFieldColumn (filter-repeater-rows.js) et -// compareValues (conditions.js), chargés avant ce fichier. +// forgeResolveVariablePath (filter-repeater-rows.js) et compareValues +// (conditions.js), chargés avant ce fichier. // // `ctx` : les {{champ}} de la ligne en cours quand cet élément est // descendant d'un Répéteur (voir render-element.js) — en mode "objet", si @@ -43,7 +43,9 @@ function forgeIsElementVisibleOffline(gameData, attrs, ctx) { var rows = gameData.data && gameData.data[definitionId]; if (!rows || !rows.length) return true; // objet sans donnée pour l'instant : ne bloque pas var row = rows[0]; // convention "état de partie" : une seule ligne - return compareValues(row[forgeFieldColumn(fieldsMetaForDef, fieldName)], operator, expected, fieldType); + // row[fieldName] : gameData.data est déjà indexé par le nom D'AFFICHAGE + // du champ (voir full_game_payload.py), jamais par sa colonne SQL. + return compareValues(row[fieldName], operator, expected, fieldType); } if (typeof module !== 'undefined' && module.exports) { @@ -51,6 +53,5 @@ if (typeof module !== 'undefined' && module.exports) { var filterMod = require('./filter-repeater-rows.js'); global.forgeResolveFilterValue = global.forgeResolveFilterValue || filterMod.forgeResolveFilterValue; global.forgeResolveVariablePath = global.forgeResolveVariablePath || filterMod.forgeResolveVariablePath; - global.forgeFieldColumn = global.forgeFieldColumn || filterMod.forgeFieldColumn; global.compareValues = global.compareValues || require('../conditions.js').compareValues; } diff --git a/tests/test_export_scorm.py b/tests/test_export_scorm.py index 46e0b7b4..52874d8d 100644 --- a/tests/test_export_scorm.py +++ b/tests/test_export_scorm.py @@ -76,6 +76,75 @@ def test_export_scorm_icon_widget_uses_a_relative_url(client, game): assert "/static/icons/star.svg" not in html +def test_export_scorm_databound_text_resolves_a_relation_field(client, game): + """Régression signalée par un utilisateur : un Texte "Donnée liée" (ou + un filtre de Répéteur) referençant un champ "relation" affichait + "{{champ}}" tel quel une fois exporté, alors qu'il fonctionnait très + bien EN LIGNE. Cause racine : full_game_payload.py construisait + gameData.data en lisant la colonne SQL "" au lieu de + "_id" (seule vraie colonne d'un champ relation, voir + _field_column dans filter_repeater_rows.py) — une valeur toujours None + qu'EN LIGNE personne ne remarquait puisque filter_repeater_rows.py + requête la base fraîche à chaque rendu, jamais ce snapshot. Le port + hors ligne (static/js/play/offline/), lui, n'a QUE ce snapshot.""" + resp = client.post( + f"/game/{game}/objects/new", + data={ + "object_name": "Monstre", + "field_name[]": ["nom"], + "field_type[]": ["texte"], + "field_relation[]": [""], + "field_required[]": ["0"], + }, + follow_redirects=False, + ) + assert resp.status_code == 302, resp.data + monstre_definition_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + resp = client.post(f"/game/{game}/objects/{monstre_definition_id}/data/new", + data={"nom": "Dragon"}, follow_redirects=False) + assert resp.status_code == 302, resp.data + import db + monstre_row_id = db.list_rows(game, db.get_definition(game, monstre_definition_id))[0]["id"] + + resp = client.post( + f"/game/{game}/objects/new", + data={ + "object_name": "Niveau", + "field_name[]": ["boss"], + "field_type[]": ["relation"], + "field_relation[]": [str(monstre_definition_id)], + "field_required[]": ["0"], + }, + follow_redirects=False, + ) + assert resp.status_code == 302, resp.data + definition_id = int(resp.headers["Location"].rstrip("/").split("/")[-1]) + resp = client.post(f"/game/{game}/objects/{definition_id}/data/new", + data={"boss_id": str(monstre_row_id)}, follow_redirects=False) + assert resp.status_code == 302, resp.data + + import re + resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False) + screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1)) + resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "texte"}, follow_redirects=False) + element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)) + resp = client.post( + f"/game/{game}/elements/{element_id}/save", + data={"ctrl_content": "Boss : {{boss}}", "ctrl_data_definition_id": str(definition_id)}, + follow_redirects=False, + ) + assert resp.status_code == 302, resp.data + + resp = client.post(f"/game/{game}/export-scorm") + import io + with zipfile.ZipFile(io.BytesIO(resp.data)) as zf: + html = zf.read("index.html").decode("utf-8") + # Le "content" brut ("Boss : {{boss}}") reste forcément présent tel + # quel dans le JSON du payload (c'est le MODÈLE stocké) — seul le + # rendered_html déjà substitué compte ici. + assert f"Boss : {monstre_row_id}" in html + + def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game): resp = client.post(f"/game/{game}/export-scorm") import io