diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md
index 9d91d7fb..e85b0e9a 100644
--- a/CODE_QUALITY.md
+++ b/CODE_QUALITY.md
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| Site(s) | Outil / règle | Raison | Contexte |
|---|---|---|---|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
-| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×2, `_render_association_player` — ajouté le 20/09/2026) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 |
+| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×3 : `_render_association_player` ×2, `_render_memory_player` ×1 — ajoutés le 20/09/2026) | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI, mélange des deux colonnes du mini-jeu Association, mélange des cartes du mini-jeu Memory) — jamais un usage cryptographique. | Phase 3 ; complété le 20/09/2026 |
| `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
diff --git a/document_engine/__init__.py b/document_engine/__init__.py
index e6da60f9..3a866686 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -41,6 +41,13 @@ from .labels.element_kind_labels import (
SHAPE_KINDS,
element_default_attributes,
)
+from .labels.memory_config import (
+ CARD_MODES,
+ DEFAULT_MEMORY_CONFIG,
+ MAX_CARDS,
+ MIN_CARDS,
+ sanitize_memory_config,
+)
from .labels.quiz_config import (
DEFAULT_QUIZ_CONFIG,
MAX_CHOICES,
@@ -53,14 +60,18 @@ from .labels.quiz_config import (
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
+ "CARD_MODES",
"CONTENT_KINDS",
"DEFAULT_ASSOCIATION_CONFIG",
+ "DEFAULT_MEMORY_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
+ "MAX_CARDS",
"MAX_CHOICES",
"MAX_PAIRS",
"MAX_TIMER_SECONDS",
+ "MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
"MIN_TIMER_SECONDS",
@@ -76,6 +87,7 @@ __all__ = [
"render_document",
"render_document_element",
"sanitize_association_config",
+ "sanitize_memory_config",
"sanitize_quiz_config",
"update_document_element_attributes",
]
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index f2a535b4..8a6271c5 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -6,6 +6,7 @@ défaut posés à la création de chaque type."""
from typing import Any
from .association_config import DEFAULT_ASSOCIATION_CONFIG
+from .memory_config import DEFAULT_MEMORY_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
@@ -86,11 +87,14 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "association":
# Même raison de copie que "quiz" ci-dessus (voir association_config.py).
return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])}
+ if kind == "memory":
+ # Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
+ return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
if kind in MINIGAME_KINDS:
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
# paragraphe : "état par défaut en attendant sa spécification") —
# décision actée : cœur complet + mini-jeux en emplacement
- # réservé (memory/mots/scenario/zones), formulaires de contenu
- # dédiés = chantier séparé.
+ # réservé (mots/scenario/zones), formulaires de contenu dédiés =
+ # chantier séparé.
return {"theme_color": "#ff5f2e"}
return {}
diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md
index 519e6cef..6fd9efd7 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -13,11 +13,11 @@ toujours en position absolue (`parent_id = NULL`).
être top-niveau ou enfants d'une rangée.
## `MINIGAME_KINDS: tuple[str, ...]`
-`("quiz", "association", "memory", "mots", "scenario", "zones")` — `"quiz"`
-et `"association"` sont implémentés (voir `quiz_config.py`/
-`association_config.py` ci-dessous) ; les 4 autres gardent un panneau
-Propriétés minimal (emplacement réservé), formulaires de contenu dédiés
-hors périmètre de cette passe.
+`("quiz", "association", "memory", "mots", "scenario", "zones")` —
+`"quiz"`, `"association"` et `"memory"` sont implémentés (voir
+`quiz_config.py`/`association_config.py`/`memory_config.py` ci-dessous) ;
+les 3 autres gardent un panneau Propriétés minimal (emplacement réservé),
+formulaires de contenu dédiés hors périmètre de cette passe.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
@@ -38,8 +38,9 @@ Attributs posés à la création d'un élément de ce type (voir
texte (`content/style` + `bold/italic/underline/align/color`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
- (`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), autre
- mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
+ (`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
+ (`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
+ (`theme_color`), ou `{}` pour un `kind` inconnu.
- **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz
@@ -102,3 +103,39 @@ levée qui ferait échouer tout le reste du mini-jeu). La liste finale est
tronquée à `MAX_PAIRS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`).
- **Exceptions** : aucune.
+
+## `memory_config.py` — modèle de données du mini-jeu Memory
+
+Troisième mini-jeu implémenté : l'apprenant retourne des cartes pour
+constituer des paires identiques (mode `"paire"`) ou simplement révéler
+chaque carte une fois (mode `"single"`, sans appariement — voir
+`document_engine/rendering/render_document_element.py`::
+`_render_memory_player`). Même convention que `quiz_config.py`.
+
+### `MIN_CARDS`, `MAX_CARDS: int`
+Bornes de validation (`2`/`8` cartes DÉFINIES par le créateur — en mode
+`"paire"`, le plateau affiche le double, chaque carte étant dupliquée).
+`MIN_CARDS` n'est pas imposé par `sanitize_memory_config` (même logique
+que `MIN_PAIRS` côté Association) — recommandation pour le panneau
+Propriétés, pas une contrainte technique du rendu.
+
+### `CARD_MODES: tuple[str, ...]`
+`("paire", "single")`.
+
+### `DEFAULT_MEMORY_CONFIG: dict[str, Any]`
+`{"theme_color": "#ff5f2e", "mode": "paire", "cards": []}`.
+
+### `sanitize_memory_config(raw_config: Any) -> dict[str, Any]`
+Valide/nettoie une config de memory arbitraire (JSON venu du client) —
+jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
+`DEFAULT_MEMORY_CONFIG`. `mode` retombe sur `"paire"` s'il n'est pas dans
+`CARD_MODES`. Chaque carte de `raw_config["cards"]` est validée
+indépendamment (voir `_sanitize_card`/`_sanitize_card_face`, privées) :
+chaque face (`recto`/`verso`) a un `image` et un `text` indépendants et
+tous deux optionnels, MAIS le `verso` doit avoir au moins l'un des deux
+non vide (rien à révéler/apparier sinon) — le `recto`, lui, peut rester
+entièrement vide (dos de carte générique "?" par défaut côté rendu). Une
+carte invalide est silencieusement supprimée de la liste. La liste finale
+est tronquée à `MAX_CARDS`.
+- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
+- **Exceptions** : aucune.
diff --git a/document_engine/labels/memory_config.py b/document_engine/labels/memory_config.py
new file mode 100644
index 00000000..1a25485e
--- /dev/null
+++ b/document_engine/labels/memory_config.py
@@ -0,0 +1,60 @@
+"""Modèle de données du mini-jeu Memory (voir docs/plan/PLAN.md §3.2) —
+l'apprenant retourne des cartes pour constituer des paires identiques
+(mode "paire") ou simplement révéler chaque carte une fois (mode
+"single", un retournement classique sans appariement). Même convention
+resolve_X/sanitize_X que quiz_config.py/association_config.py (aucun
+import croisé)."""
+
+from typing import Any
+
+MIN_CARDS = 2
+MAX_CARDS = 8
+CARD_MODES = ("paire", "single")
+DEFAULT_MODE = "paire"
+
+DEFAULT_MEMORY_CONFIG: dict[str, Any] = {
+ "theme_color": "#ff5f2e",
+ "mode": DEFAULT_MODE,
+ "cards": [],
+}
+
+
+def _sanitize_card_face(raw: Any) -> dict[str, str]:
+ """Une face de carte (recto ou verso) — image et texte tous deux
+ optionnels et indépendants (le créateur peut mettre l'un, l'autre, ou
+ les deux, voir docs/plan/PLAN.md)."""
+ if not isinstance(raw, dict):
+ return {"image": "", "text": ""}
+ return {
+ "image": str(raw.get("image", "")).strip(),
+ "text": str(raw.get("text", "")).strip(),
+ }
+
+
+def _sanitize_card(raw: Any) -> dict[str, Any] | None:
+ """None si la carte est invalide — un verso entièrement vide (ni image
+ ni texte) n'aurait rien à révéler/apparier, contrairement au recto qui
+ peut légitimement rester vide (dos de carte générique par défaut)."""
+ if not isinstance(raw, dict):
+ return None
+ recto = _sanitize_card_face(raw.get("recto"))
+ verso = _sanitize_card_face(raw.get("verso"))
+ if not verso["image"] and not verso["text"]:
+ return None
+ return {"recto": recto, "verso": verso}
+
+
+def sanitize_memory_config(raw_config: Any) -> dict[str, Any]:
+ config = dict(DEFAULT_MEMORY_CONFIG)
+ if not isinstance(raw_config, dict):
+ return config
+ theme_color = raw_config.get("theme_color")
+ if isinstance(theme_color, str) and theme_color:
+ config["theme_color"] = theme_color
+ mode = raw_config.get("mode")
+ config["mode"] = mode if mode in CARD_MODES else DEFAULT_MODE
+ raw_cards = raw_config.get("cards")
+ if isinstance(raw_cards, list):
+ cards = [c for c in (_sanitize_card(item) for item in raw_cards) if c is not None]
+ config["cards"] = cards[:MAX_CARDS]
+ return config
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index 26f01553..d26e7637 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -250,6 +250,64 @@ def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None
)
+def _render_memory_player(config: dict[str, Any]) -> str:
+ """Plateau de Memory RÉELLEMENT interactif — affiché uniquement en
+ Mode Aperçu, même principe que _render_quiz_player/
+ _render_association_player. En mode "paire", chaque carte définie par
+ le créateur est dupliquée en deux instances partageant le même
+ card_index (l'appariement se fait dessus) ; en mode "single", une
+ seule instance par carte (simple retournement, sans appariement).
+ Les instances sont mélangées une seule fois ici (jamais recalculées
+ à chaque rendu répété d'un même Aperçu, voir la remarque dans
+ static/document/js/document-editor.js sur la ré-init au
+ rafraîchissement du canevas) puis embarquées en JSON."""
+ cards = config["cards"]
+ mode = config["mode"]
+ instances = []
+ for i, card in enumerate(cards):
+ instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
+ if mode == "paire":
+ instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
+ random.shuffle(instances) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
+ config_json = html_lib.escape(json.dumps({"mode": mode, "cards": instances}), quote=True)
+ return (
+ f'