diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md
index 7b082234..9d91d7fb 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` | `B311`/`S311`/`python:S2245`/`javascript:S2245` | Tirage aléatoire de jeu (dé, id local d'UI) — jamais un usage cryptographique. | 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 |
| `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 2495573c..e6da60f9 100644
--- a/document_engine/__init__.py
+++ b/document_engine/__init__.py
@@ -27,6 +27,12 @@ from .elements.get_document_element import get_document_element
from .elements.list_document_elements import list_document_elements
from .elements.move_document_element import move_document_element
from .elements.update_document_element_attributes import update_document_element_attributes
+from .labels.association_config import (
+ DEFAULT_ASSOCIATION_CONFIG,
+ MAX_PAIRS,
+ MIN_PAIRS,
+ sanitize_association_config,
+)
from .labels.element_kind_labels import (
CONTENT_KINDS,
ELEMENT_KIND_LABELS,
@@ -48,12 +54,15 @@ from .rendering.render_document_element import render_document, render_document_
__all__ = [
"CONTENT_KINDS",
+ "DEFAULT_ASSOCIATION_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MAX_CHOICES",
+ "MAX_PAIRS",
"MAX_TIMER_SECONDS",
"MIN_CHOICES",
+ "MIN_PAIRS",
"MIN_TIMER_SECONDS",
"MINIGAME_KINDS",
"SHAPE_KINDS",
@@ -66,6 +75,7 @@ __all__ = [
"quiz_total_points",
"render_document",
"render_document_element",
+ "sanitize_association_config",
"sanitize_quiz_config",
"update_document_element_attributes",
]
diff --git a/document_engine/labels/association_config.py b/document_engine/labels/association_config.py
new file mode 100644
index 00000000..29fc7359
--- /dev/null
+++ b/document_engine/labels/association_config.py
@@ -0,0 +1,43 @@
+"""Modèle de données du mini-jeu Association (voir docs/plan/PLAN.md §3.2)
+— l'apprenant relie chaque carte de gauche à sa correspondance de droite
+par glisser-déposer (voir document_engine/rendering/render_document_element.py
+::_render_association_player). Même convention resolve_X/sanitize_X que
+quiz_config.py et game_engine/rendering/quiz_box_config.py côté jeu
+(aucun import croisé)."""
+
+from typing import Any
+
+MIN_PAIRS = 2
+MAX_PAIRS = 8
+
+DEFAULT_ASSOCIATION_CONFIG: dict[str, Any] = {
+ "theme_color": "#ff5f2e",
+ "pairs": [],
+}
+
+
+def _sanitize_pair(raw: Any) -> dict[str, str] | None:
+ """None si la paire est invalide (un des deux côtés vide) — filtrée par
+ sanitize_association_config plutôt que de faire échouer tout le
+ mini-jeu, même convention que quiz_config.py::_sanitize_question."""
+ if not isinstance(raw, dict):
+ return None
+ left = str(raw.get("left", "")).strip()
+ right = str(raw.get("right", "")).strip()
+ if not left or not right:
+ return None
+ return {"left": left, "right": right}
+
+
+def sanitize_association_config(raw_config: Any) -> dict[str, Any]:
+ config = dict(DEFAULT_ASSOCIATION_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
+ raw_pairs = raw_config.get("pairs")
+ if isinstance(raw_pairs, list):
+ pairs = [p for p in (_sanitize_pair(item) for item in raw_pairs) if p is not None]
+ config["pairs"] = pairs[:MAX_PAIRS]
+ return config
diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py
index 2bf0e4aa..f2a535b4 100644
--- a/document_engine/labels/element_kind_labels.py
+++ b/document_engine/labels/element_kind_labels.py
@@ -5,6 +5,7 @@ défaut posés à la création de chaque type."""
from typing import Any
+from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
@@ -77,17 +78,19 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "row":
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
if kind == "quiz":
- # Seul mini-jeu implémenté pour l'instant (voir quiz_config.py) —
- # les autres restent un simple emplacement réservé ci-dessous.
# Copie de "questions" (liste, jamais un simple dict(...) qui la
# partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant
# ne la mute en place aujourd'hui, mais la copier ici coûte rien et
# évite d'ancrer cette hypothèse fragile pour la suite.
return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])}
+ 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 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é, formulaires de contenu dédiés = chantier séparé.
+ # réservé (memory/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 cc49c519..519e6cef 100644
--- a/document_engine/labels/labels.md
+++ b/document_engine/labels/labels.md
@@ -13,10 +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")` — seul
-`"quiz"` est implémenté (voir `quiz_config.py` ci-dessous) ; les 5 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"`
+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.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
@@ -36,8 +37,9 @@ Attributs posés à la création d'un élément de ce type (voir
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
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`), autre mini-jeu
- (`theme_color`), ou `{}` pour un `kind` inconnu.
+ 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.
- **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz
@@ -72,3 +74,31 @@ mirroir de `db/dialogue_lines.py::sum_question_rewards` côté jeu, utile
le jour où un export calculera un score maximum.
- **Retour** : entier ≥ 0.
- **Exceptions** : aucune.
+
+## `association_config.py` — modèle de données du mini-jeu Association
+
+Deuxième mini-jeu implémenté : l'apprenant relie chaque carte de gauche
+("terme") à sa correspondance de droite ("définition") par glisser-déposer
+(voir `document_engine/rendering/render_document_element.py`::
+`_render_association_player`). Même convention que `quiz_config.py`.
+
+### `MIN_PAIRS`, `MAX_PAIRS: int`
+Bornes de validation (`2`/`8` paires). `MIN_PAIRS` n'est pas imposé par
+`sanitize_association_config` (une seule paire valide reste acceptée,
+comme un quiz à une seule question) — c'est une recommandation pour le
+panneau Propriétés, pas une contrainte technique du rendu.
+
+### `DEFAULT_ASSOCIATION_CONFIG: dict[str, Any]`
+`{"theme_color": "#ff5f2e", "pairs": []}`.
+
+### `sanitize_association_config(raw_config: Any) -> dict[str, Any]`
+Valide/nettoie une config d'association arbitraire (JSON venu du client)
+— jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
+`DEFAULT_ASSOCIATION_CONFIG`. Chaque paire de `raw_config["pairs"]` est
+validée indépendamment (voir `_sanitize_pair`, privée) : les deux côtés
+(`left`/`right`) doivent être non vides une fois `.strip()`-és, sinon la
+paire entière est silencieusement supprimée de la liste (jamais une
+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.
diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py
index f3d74caf..26f01553 100644
--- a/document_engine/rendering/render_document_element.py
+++ b/document_engine/rendering/render_document_element.py
@@ -1,5 +1,6 @@
import html as html_lib
import json
+import random
from typing import Any
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
@@ -193,6 +194,62 @@ def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[
)
+def _render_association_player(config: dict[str, Any]) -> str:
+ """Plateau de glisser-déposer RÉELLEMENT interactif — affiché
+ uniquement en Mode Aperçu, même principe que _render_quiz_player :
+ aucun aller-retour serveur, tout le déroulé (glisser une carte de
+ gauche sur son emplacement de droite, ou cliquer les deux) est géré
+ par static/document/js/document-editor.js à partir du JSON embarqué.
+ Les deux colonnes sont mélangées INDÉPENDAMMENT (sinon la position
+ suffirait à deviner l'association, sans avoir à lire quoi que ce
+ soit) — random.shuffle : mélange d'affichage pour un mini-jeu,
+ jamais un usage cryptographique (voir CODE_QUALITY.md)."""
+ pairs = config["pairs"]
+ left_items = [{"pair_index": i, "text": p["left"]} for i, p in enumerate(pairs)]
+ right_items = [{"pair_index": i, "text": p["right"]} for i, p in enumerate(pairs)]
+ random.shuffle(left_items) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
+ random.shuffle(right_items) # NOSONAR python:S2245 - idem
+ config_json = html_lib.escape(json.dumps({"left": left_items, "right": right_items}), quote=True)
+ return (
+ f'