Implemente le mini-jeu Association (glisser-deposer par paires)
document_engine/labels/association_config.py (nouveau) : modele de
donnees, meme convention resolve_X/sanitize_X que quiz_config.py -
DEFAULT_ASSOCIATION_CONFIG, sanitize_association_config (chaque paire
doit avoir ses deux cotes non vides, sinon supprimee silencieusement ;
liste tronquee a MAX_PAIRS=8).
Cote serveur, routes/document/document_element_update.py revalide
desormais aussi l'association avant persistance (meme raisonnement que
pour le quiz). Le rendu (_render_association) affiche un resume reel
(nombre de paires) et, des qu'au moins une paire existe, un plateau de
glisser-deposer REELEMENT interactif en Mode Apercu
(_render_association_player) : les deux colonnes (termes/
correspondances) sont melangees independamment (random.shuffle,
melange d'affichage documente dans CODE_QUALITY.md) puis embarquees en
JSON dans un attribut data-assoc-config.
Cote editeur, le panneau Proprietes d'une association ("relier
visuellement deux champs qui vont ensemble") est une liste de paires
repetable, chaque ligne reliant visuellement un champ "Element" et un
champ "Correspondance" par un glyphe ↔. Le plateau jouable en Apercu
(static/document/js/document-editor.js) supporte deux facons de jouer,
toutes deux reelles : glisser-deposer HTML5 natif, ou cliquer une
carte puis son emplacement (repli pour les appareils sans support
fiable du drag) - bonne association verrouillee en vert, mauvaise
signalee puis reinitialisee, ecran de resultat une fois toutes les
paires associees.
Bug reel trouve ET corrige via simulation DOM complete (glisser-
deposer + clic simules, pas juste un chargement de page) : le
feedback visuel reutilisait la classe CSS du quiz via une
reaffectation de className qui supprimait au passage la classe
d'identite docAssocFeedback, rendant l'element introuvable des le
premier essai de match (aurait plante en usage reel des la premiere
tentative). Corrige en gardant toujours les deux classes ensemble.
Tests : 8 tests purs (tests/document/test_association_config.py, sans
Flask) + 1 test de route verifiant la sanitization a l'ecriture.
SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 50 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur paire invalide, rendu du plateau,
et simulation DOM du gameplay reel (glisser-deposer correct/incorrect,
clic-selection, progression, ecran de resultat) - script de
diagnostic non conserve dans le depot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9fb22be1ae
commit
ce750ec697
+1
-1
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
|||||||
| Site(s) | Outil / règle | Raison | Contexte |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ from .elements.get_document_element import get_document_element
|
|||||||
from .elements.list_document_elements import list_document_elements
|
from .elements.list_document_elements import list_document_elements
|
||||||
from .elements.move_document_element import move_document_element
|
from .elements.move_document_element import move_document_element
|
||||||
from .elements.update_document_element_attributes import update_document_element_attributes
|
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 (
|
from .labels.element_kind_labels import (
|
||||||
CONTENT_KINDS,
|
CONTENT_KINDS,
|
||||||
ELEMENT_KIND_LABELS,
|
ELEMENT_KIND_LABELS,
|
||||||
@@ -48,12 +54,15 @@ from .rendering.render_document_element import render_document, render_document_
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CONTENT_KINDS",
|
"CONTENT_KINDS",
|
||||||
|
"DEFAULT_ASSOCIATION_CONFIG",
|
||||||
"DEFAULT_QUIZ_CONFIG",
|
"DEFAULT_QUIZ_CONFIG",
|
||||||
"ELEMENT_KIND_LABELS",
|
"ELEMENT_KIND_LABELS",
|
||||||
"ELEMENT_LIBRARY",
|
"ELEMENT_LIBRARY",
|
||||||
"MAX_CHOICES",
|
"MAX_CHOICES",
|
||||||
|
"MAX_PAIRS",
|
||||||
"MAX_TIMER_SECONDS",
|
"MAX_TIMER_SECONDS",
|
||||||
"MIN_CHOICES",
|
"MIN_CHOICES",
|
||||||
|
"MIN_PAIRS",
|
||||||
"MIN_TIMER_SECONDS",
|
"MIN_TIMER_SECONDS",
|
||||||
"MINIGAME_KINDS",
|
"MINIGAME_KINDS",
|
||||||
"SHAPE_KINDS",
|
"SHAPE_KINDS",
|
||||||
@@ -66,6 +75,7 @@ __all__ = [
|
|||||||
"quiz_total_points",
|
"quiz_total_points",
|
||||||
"render_document",
|
"render_document",
|
||||||
"render_document_element",
|
"render_document_element",
|
||||||
|
"sanitize_association_config",
|
||||||
"sanitize_quiz_config",
|
"sanitize_quiz_config",
|
||||||
"update_document_element_attributes",
|
"update_document_element_attributes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -5,6 +5,7 @@ défaut posés à la création de chaque type."""
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from .association_config import DEFAULT_ASSOCIATION_CONFIG
|
||||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||||
|
|
||||||
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
||||||
@@ -77,17 +78,19 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
|||||||
if kind == "row":
|
if kind == "row":
|
||||||
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
||||||
if kind == "quiz":
|
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
|
# Copie de "questions" (liste, jamais un simple dict(...) qui la
|
||||||
# partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant
|
# 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
|
# 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.
|
# évite d'ancrer cette hypothèse fragile pour la suite.
|
||||||
return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])}
|
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:
|
if kind in MINIGAME_KINDS:
|
||||||
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
||||||
# paragraphe : "état par défaut en attendant sa spécification") —
|
# paragraphe : "état par défaut en attendant sa spécification") —
|
||||||
# décision actée : cœur complet + mini-jeux en emplacement
|
# 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 {"theme_color": "#ff5f2e"}
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ toujours en position absolue (`parent_id = NULL`).
|
|||||||
être top-niveau ou enfants d'une rangée.
|
être top-niveau ou enfants d'une rangée.
|
||||||
|
|
||||||
## `MINIGAME_KINDS: tuple[str, ...]`
|
## `MINIGAME_KINDS: tuple[str, ...]`
|
||||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` — seul
|
`("quiz", "association", "memory", "mots", "scenario", "zones")` — `"quiz"`
|
||||||
`"quiz"` est implémenté (voir `quiz_config.py` ci-dessous) ; les 5 autres
|
et `"association"` sont implémentés (voir `quiz_config.py`/
|
||||||
gardent un panneau Propriétés minimal (emplacement réservé), formulaires
|
`association_config.py` ci-dessous) ; les 4 autres gardent un panneau
|
||||||
de contenu dédiés hors périmètre de cette passe.
|
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]]`
|
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
|
||||||
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
|
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`),
|
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
|
||||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
texte (`content/style` + `bold/italic/underline/align/color`),
|
||||||
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
|
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
|
||||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), autre mini-jeu
|
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
|
||||||
(`theme_color`), ou `{}` pour un `kind` inconnu.
|
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), autre
|
||||||
|
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
|
||||||
- **Exceptions** : aucune.
|
- **Exceptions** : aucune.
|
||||||
|
|
||||||
## `quiz_config.py` — modèle de données du mini-jeu Quiz
|
## `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.
|
le jour où un export calculera un score maximum.
|
||||||
- **Retour** : entier ≥ 0.
|
- **Retour** : entier ≥ 0.
|
||||||
- **Exceptions** : aucune.
|
- **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.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import html as html_lib
|
import html as html_lib
|
||||||
import json
|
import json
|
||||||
|
import random
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
|
_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'<div class="docAssocPlayer" data-assoc-config="{config_json}">'
|
||||||
|
f'<div class="docAssocCard">'
|
||||||
|
f'<div class="docQuizKicker">Association</div>'
|
||||||
|
f'<div class="docAssocTitle">Associez chaque élément à sa correspondance</div>'
|
||||||
|
f'<div class="docAssocMeta"><span class="docAssocProg"></span></div>'
|
||||||
|
f'<div class="docAssocBoard">'
|
||||||
|
f'<div class="docAssocColumn docAssocColumnLeft"></div>'
|
||||||
|
f'<div class="docAssocColumn docAssocColumnRight"></div>'
|
||||||
|
f"</div>"
|
||||||
|
f'<div class="docAssocFeedback"></div>'
|
||||||
|
f"</div>"
|
||||||
|
f'<div class="docAssocResultCard" style="display:none;">'
|
||||||
|
f'<div class="docAssocResultBig">✓</div>'
|
||||||
|
f'<div class="docAssocResultSub">Toutes les paires sont associées !</div>'
|
||||||
|
f'<button type="button" class="docAssocRestartBtn">Recommencer</button>'
|
||||||
|
f"</div></div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
from ..labels.association_config import sanitize_association_config
|
||||||
|
|
||||||
|
config = sanitize_association_config(el["attributes"])
|
||||||
|
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||||
|
pair_count = len(config["pairs"])
|
||||||
|
pair_label = "paire" if pair_count <= 1 else "paires"
|
||||||
|
player_html = _render_association_player(config) if config["pairs"] else ""
|
||||||
|
return (
|
||||||
|
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="association" '
|
||||||
|
f'style="border-color:{theme_color};">'
|
||||||
|
f'<div class="docMinigameBadge">'
|
||||||
|
f'<span class="docMinigameLabel">Association</span>'
|
||||||
|
f'<span class="docMinigamePlaceholder">{pair_count} {pair_label}</span>'
|
||||||
|
f"</div>"
|
||||||
|
f"{player_html}"
|
||||||
|
f"</div>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
||||||
|
|
||||||
@@ -208,7 +265,7 @@ _RENDERERS = {
|
|||||||
"image": _render_image,
|
"image": _render_image,
|
||||||
"bouton": _render_button,
|
"bouton": _render_button,
|
||||||
"quiz": _render_quiz,
|
"quiz": _render_quiz,
|
||||||
"association": _render_minigame_placeholder,
|
"association": _render_association,
|
||||||
"memory": _render_minigame_placeholder,
|
"memory": _render_minigame_placeholder,
|
||||||
"mots": _render_minigame_placeholder,
|
"mots": _render_minigame_placeholder,
|
||||||
"scenario": _render_minigame_placeholder,
|
"scenario": _render_minigame_placeholder,
|
||||||
|
|||||||
@@ -48,7 +48,17 @@ regroupement à chaque appel.
|
|||||||
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
|
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
|
||||||
lit cet attribut et gère tout le déroulé (réponse/score/question
|
lit cet attribut et gère tout le déroulé (réponse/score/question
|
||||||
suivante/résultat) côté client, sans aucun aller-retour serveur.
|
suivante/résultat) côté client, sans aucun aller-retour serveur.
|
||||||
- **Autres mini-jeux** (`association`/`memory`/`mots`/`scenario`/`zones`) :
|
- **Association** : toujours une carte résumant la config réelle (nombre
|
||||||
carte placeholder portant le libellé du type (voir
|
de paires) — sanitizée (`sanitize_association_config`) avant lecture.
|
||||||
|
Si au moins une paire existe, s'y ajoute (fonction privée
|
||||||
|
`_render_association_player`) le plateau de glisser-déposer RÉEL et
|
||||||
|
interactif affiché en Mode Aperçu, masqué en édition par CSS
|
||||||
|
(`.docAssocPlayer`) : les deux colonnes (termes/correspondances) sont
|
||||||
|
mélangées INDÉPENDAMMENT (`random.shuffle`, mélange d'affichage — voir
|
||||||
|
`CODE_QUALITY.md`) puis embarquées en JSON dans un attribut
|
||||||
|
`data-assoc-config`, échappé pour l'HTML — même principe que le Quiz,
|
||||||
|
aucun aller-retour serveur pendant qu'on joue.
|
||||||
|
- **Autres mini-jeux** (`memory`/`mots`/`scenario`/`zones`) : carte
|
||||||
|
placeholder portant le libellé du type (voir
|
||||||
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
|
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
|
||||||
formulaire de contenu dédié hors périmètre de cette passe.
|
formulaire de contenu dédié hors périmètre de cette passe.
|
||||||
|
|||||||
@@ -14,19 +14,21 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
|
|||||||
Propriétés (voir static/document/js/document-editor.js) envoie
|
Propriétés (voir static/document/js/document-editor.js) envoie
|
||||||
systématiquement l'état complet de ses champs.
|
systématiquement l'état complet de ses champs.
|
||||||
|
|
||||||
Un quiz est le seul kind revalidé côté serveur (sanitize_quiz_config,
|
Quiz et Association sont les seuls kinds revalidés côté serveur
|
||||||
même convention que game_engine/rendering/quiz_box_config.py côté
|
(sanitize_quiz_config/sanitize_association_config, même convention que
|
||||||
jeu) : sa forme (liste de questions/choix/index de bonne réponse) doit
|
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste
|
||||||
rester structurellement correcte pour que le rendu ne plante jamais,
|
de questions/choix, liste de paires) doit rester structurellement
|
||||||
contrairement aux autres kinds (texte/forme/image...) dont les
|
correcte pour que le rendu ne plante jamais, contrairement aux autres
|
||||||
attributs sont de simples valeurs scalaires sans structure à
|
kinds (texte/forme/image...) dont les attributs sont de simples
|
||||||
garantir."""
|
valeurs scalaires sans structure à garantir."""
|
||||||
element = document_engine.get_document_element(slug, element_id)
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
if element is None:
|
if element is None:
|
||||||
return jsonify({"error": "élément introuvable"}), 404
|
return jsonify({"error": "élément introuvable"}), 404
|
||||||
attributes: dict[str, Any] = request.get_json(force=True) or {}
|
attributes: dict[str, Any] = request.get_json(force=True) or {}
|
||||||
if element["kind"] == "quiz":
|
if element["kind"] == "quiz":
|
||||||
attributes = document_engine.sanitize_quiz_config(attributes)
|
attributes = document_engine.sanitize_quiz_config(attributes)
|
||||||
|
elif element["kind"] == "association":
|
||||||
|
attributes = document_engine.sanitize_association_config(attributes)
|
||||||
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
element = db.assert_not_none(
|
element = db.assert_not_none(
|
||||||
document_engine.get_document_element(slug, element_id),
|
document_engine.get_document_element(slug, element_id),
|
||||||
|
|||||||
@@ -500,33 +500,39 @@ img.docImage {
|
|||||||
color: var(--doc-muted);
|
color: var(--doc-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Quiz : questionnaire interactif en Mode Aperçu (voir docs/plan/
|
/* ---- Quiz/Association : mini-jeux interactifs en Mode Aperçu (voir
|
||||||
maquettes/document-formation-web.html — référence visuelle). Masqué en
|
docs/plan/maquettes/document-formation-web.html — référence visuelle
|
||||||
édition (seul .docMinigameBadge y est visible, voir plus haut) ; les
|
du Quiz). Masqués en édition (seul .docMinigameBadge y est visible,
|
||||||
deux jamais visibles en même temps. ---- */
|
voir plus haut) ; jamais les deux visibles en même temps. ---- */
|
||||||
.docQuizPlayer {
|
.docQuizPlayer,
|
||||||
|
.docAssocPlayer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge {
|
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
|
||||||
|
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer {
|
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
|
||||||
|
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview [data-kind="quiz"] {
|
.docEditor3.docEditor3--preview [data-kind="quiz"],
|
||||||
|
.docEditor3.docEditor3--preview [data-kind="association"] {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview .docQuizPlayer,
|
.docEditor3.docEditor3--preview .docQuizPlayer,
|
||||||
.docEditor3.docEditor3--preview .docQuizPlayer * {
|
.docEditor3.docEditor3--preview .docQuizPlayer *,
|
||||||
|
.docEditor3.docEditor3--preview .docAssocPlayer,
|
||||||
|
.docEditor3.docEditor3--preview .docAssocPlayer * {
|
||||||
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
|
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
|
||||||
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
||||||
l'Aperçu — réactivé ici spécifiquement pour que le quiz reste
|
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
|
||||||
RÉELLEMENT cliquable (répondre, question suivante, recommencer). */
|
RÉELLEMENT cliquables/glissables. */
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,6 +737,135 @@ img.docImage {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Association : plateau de glisser-déposer (voir docs/plan/PLAN.md,
|
||||||
|
"relier visuellement deux éléments qui vont ensemble"). Réutilise
|
||||||
|
.docQuizFeedback/.docQuizKicker (même rôle visuel) plutôt que de
|
||||||
|
dupliquer ces règles. ---- */
|
||||||
|
.docAssocCard,
|
||||||
|
.docAssocResultCard {
|
||||||
|
background: linear-gradient(180deg, var(--doc-card), var(--doc-bg-2));
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocResultCard {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocTitle {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocMeta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--doc-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocBoard {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocColumn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocItem,
|
||||||
|
.docAssocSlot {
|
||||||
|
padding: 13px 15px;
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
background: var(--doc-bg-2);
|
||||||
|
color: var(--doc-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: border-color 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocItem {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocItem:hover,
|
||||||
|
.docAssocSlot:hover {
|
||||||
|
border-color: var(--doc-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocItem.is-selected {
|
||||||
|
border-color: var(--doc-accent);
|
||||||
|
box-shadow: var(--doc-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocItem.is-matched,
|
||||||
|
.docAssocSlot.is-matched {
|
||||||
|
border-color: var(--doc-quiz-success-border);
|
||||||
|
background: var(--doc-quiz-success-bg);
|
||||||
|
color: var(--doc-quiz-success-text);
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocItem.is-wrong,
|
||||||
|
.docAssocSlot.is-wrong {
|
||||||
|
border-color: var(--doc-quiz-danger-border);
|
||||||
|
background: var(--doc-quiz-danger-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocRestartBtn {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
color: var(--doc-text);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocResultBig {
|
||||||
|
font-size: 40px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--doc-quiz-success-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocResultSub {
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--doc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Association : panneau Propriétés — paires reliées visuellement par
|
||||||
|
un glyphe entre les deux champs (voir forgeDocRenderAssociationPairHtml,
|
||||||
|
static/document/js/document-editor.js). ---- */
|
||||||
|
.docAssocPairRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocPairInput {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docAssocPairLink {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--doc-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.docUnknown {
|
.docUnknown {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--doc-muted);
|
color: var(--doc-muted);
|
||||||
@@ -1119,4 +1254,8 @@ img.docImage {
|
|||||||
.docBottomNav {
|
.docBottomNav {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.docAssocBoard {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -476,6 +476,7 @@ function forgeDocBindCanvasInteractions() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
forgeDocInitQuizPlayers();
|
forgeDocInitQuizPlayers();
|
||||||
|
forgeDocInitAssociationPlayers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------
|
/* ---------------------------------------------------------------------
|
||||||
@@ -603,6 +604,148 @@ function forgeDocInitQuizPlayers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
* Association — glisser-déposer RÉELLEMENT interactif en Mode Aperçu :
|
||||||
|
* relier chaque carte de gauche à sa correspondance de droite. Deux
|
||||||
|
* façons de jouer, toutes deux réelles (pas de simulation) : glisser-
|
||||||
|
* déposer natif HTML5, ou cliquer une carte puis son emplacement (pour
|
||||||
|
* les appareils sans support fiable du drag). État tenu en mémoire,
|
||||||
|
* jamais persisté.
|
||||||
|
* ------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function forgeDocAssociationPlayerData(playerEl) {
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(playerEl.dataset.assocConfig || '{}');
|
||||||
|
return {
|
||||||
|
left: Array.isArray(config.left) ? config.left : [],
|
||||||
|
right: Array.isArray(config.right) ? config.right : [],
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { left: [], right: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocAssociationMatchResult(playerEl, state, pairIndex, itemEl, slotEl, isMatch) {
|
||||||
|
const feedbackEl = playerEl.querySelector('.docAssocFeedback');
|
||||||
|
if (!isMatch) {
|
||||||
|
// "docAssocFeedback" doit rester dans la liste (c'est la classe qui
|
||||||
|
// permet de retrouver cet élément — voir querySelector ci-dessus) :
|
||||||
|
// une réaffectation de className qui ne garde que "docQuizFeedback..."
|
||||||
|
// (réutilisé pour le STYLE visuel) la supprimerait silencieusement,
|
||||||
|
// rendant l'élément introuvable dès le prochain appel (bug réel
|
||||||
|
// trouvé le 20/09/2026 via simulation DOM).
|
||||||
|
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ko';
|
||||||
|
feedbackEl.textContent = "Ce n'est pas la bonne association, réessaie.";
|
||||||
|
itemEl.classList.add('is-wrong');
|
||||||
|
slotEl.classList.add('is-wrong');
|
||||||
|
setTimeout(() => {
|
||||||
|
itemEl.classList.remove('is-wrong');
|
||||||
|
slotEl.classList.remove('is-wrong');
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
itemEl.classList.add('is-matched');
|
||||||
|
itemEl.removeAttribute('draggable');
|
||||||
|
slotEl.classList.add('is-matched');
|
||||||
|
state.matched.add(pairIndex);
|
||||||
|
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ok';
|
||||||
|
feedbackEl.textContent = 'Bonne association !';
|
||||||
|
playerEl.querySelector('.docAssocProg').textContent = `${state.matched.size} / ${state.total} associées`;
|
||||||
|
if (state.matched.size >= state.total) {
|
||||||
|
playerEl.querySelector('.docAssocCard').style.display = 'none';
|
||||||
|
playerEl.querySelector('.docAssocResultCard').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderAssociationBoard(playerEl, state) {
|
||||||
|
const leftCol = playerEl.querySelector('.docAssocColumnLeft');
|
||||||
|
const rightCol = playerEl.querySelector('.docAssocColumnRight');
|
||||||
|
leftCol.innerHTML = '';
|
||||||
|
rightCol.innerHTML = '';
|
||||||
|
|
||||||
|
state.left.forEach((item) => {
|
||||||
|
const itemEl = document.createElement('div');
|
||||||
|
itemEl.className = 'docAssocItem';
|
||||||
|
itemEl.textContent = item.text;
|
||||||
|
itemEl.dataset.pairIndex = String(item.pair_index);
|
||||||
|
itemEl.setAttribute('draggable', 'true');
|
||||||
|
itemEl.addEventListener('dragstart', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.dataTransfer.setData('text/forge-assoc-pair', String(item.pair_index));
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
itemEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (state.matched.has(item.pair_index)) return;
|
||||||
|
if (state.selected) state.selected.classList.remove('is-selected');
|
||||||
|
state.selected = itemEl;
|
||||||
|
state.selectedPairIndex = item.pair_index;
|
||||||
|
itemEl.classList.add('is-selected');
|
||||||
|
});
|
||||||
|
leftCol.appendChild(itemEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
state.right.forEach((item) => {
|
||||||
|
const slotEl = document.createElement('div');
|
||||||
|
slotEl.className = 'docAssocSlot';
|
||||||
|
slotEl.textContent = item.text;
|
||||||
|
slotEl.dataset.pairIndex = String(item.pair_index);
|
||||||
|
slotEl.addEventListener('dragover', (e) => {
|
||||||
|
if (!state.matched.has(item.pair_index)) e.preventDefault();
|
||||||
|
});
|
||||||
|
slotEl.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (state.matched.has(item.pair_index)) return;
|
||||||
|
const draggedPairIndex = Number(e.dataTransfer.getData('text/forge-assoc-pair'));
|
||||||
|
const itemEl = leftCol.querySelector(`.docAssocItem[data-pair-index="${draggedPairIndex}"]`);
|
||||||
|
if (!itemEl) return;
|
||||||
|
const isMatch = draggedPairIndex === item.pair_index;
|
||||||
|
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
|
||||||
|
});
|
||||||
|
slotEl.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (state.matched.has(item.pair_index) || !state.selected) return;
|
||||||
|
const itemEl = state.selected;
|
||||||
|
const { selectedPairIndex } = state;
|
||||||
|
state.selected.classList.remove('is-selected');
|
||||||
|
state.selected = null;
|
||||||
|
const isMatch = selectedPairIndex === item.pair_index;
|
||||||
|
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
|
||||||
|
});
|
||||||
|
rightCol.appendChild(slotEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocAssociationRestart(playerEl) {
|
||||||
|
const data = forgeDocAssociationPlayerData(playerEl);
|
||||||
|
const state = {
|
||||||
|
left: data.left,
|
||||||
|
right: data.right,
|
||||||
|
matched: new Set(),
|
||||||
|
total: data.left.length,
|
||||||
|
selected: null,
|
||||||
|
selectedPairIndex: null,
|
||||||
|
};
|
||||||
|
playerEl.querySelector('.docAssocResultCard').style.display = 'none';
|
||||||
|
playerEl.querySelector('.docAssocCard').style.display = 'block';
|
||||||
|
playerEl.querySelector('.docAssocFeedback').className = 'docAssocFeedback docQuizFeedback';
|
||||||
|
playerEl.querySelector('.docAssocProg').textContent = `0 / ${state.total} associées`;
|
||||||
|
forgeDocRenderAssociationBoard(playerEl, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocInitAssociationPlayers() {
|
||||||
|
document.querySelectorAll('.docAssocPlayer').forEach((playerEl) => {
|
||||||
|
const data = forgeDocAssociationPlayerData(playerEl);
|
||||||
|
if (!data.left.length) return;
|
||||||
|
forgeDocAssociationRestart(playerEl);
|
||||||
|
playerEl.querySelector('.docAssocRestartBtn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
forgeDocAssociationRestart(playerEl);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocSetActiveBottomNav(target) {
|
function forgeDocSetActiveBottomNav(target) {
|
||||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||||
b.classList.toggle('is-active', b.dataset.target === target);
|
b.classList.toggle('is-active', b.dataset.target === target);
|
||||||
@@ -930,6 +1073,79 @@ function forgeDocRenderQuizProps(panel, el) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocAssociationNewPair() {
|
||||||
|
return { left: '', right: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
|
||||||
|
return `
|
||||||
|
<div class="docAssocPairRow" data-pair-index="${pIndex}">
|
||||||
|
<input type="text"
|
||||||
|
class="docAssocPairInput"
|
||||||
|
value="${pair.left}"
|
||||||
|
placeholder="Élément"
|
||||||
|
data-pair-index="${pIndex}"
|
||||||
|
data-side="left">
|
||||||
|
<span class="docAssocPairLink" aria-hidden="true">↔</span>
|
||||||
|
<input type="text"
|
||||||
|
class="docAssocPairInput"
|
||||||
|
value="${pair.right}"
|
||||||
|
placeholder="Correspondance"
|
||||||
|
data-pair-index="${pIndex}"
|
||||||
|
data-side="right">
|
||||||
|
<button type="button" class="docQuizRemoveBtn" data-pair-index="${pIndex}" title="Supprimer cette paire">🗑</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderAssociationProps(panel, el) {
|
||||||
|
const a = el.attributes;
|
||||||
|
const pairs = a.pairs || [];
|
||||||
|
|
||||||
|
function patch(partial) {
|
||||||
|
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||||
|
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
${forgeDocPropsHeader('association')}
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Couleur du thème</span>
|
||||||
|
<div class="docSwatches" id="docAssocColor">
|
||||||
|
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docSectionLabel docQuizQuestionsLabel">Paires à relier</div>
|
||||||
|
<div id="docAssocPairsList">${pairs.length ? pairs.map(forgeDocRenderAssociationPairHtml).join('') : '<div class="docEmptyState">Aucune paire — ajoute la première ci-dessous.</div>'}</div>
|
||||||
|
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docAssocAddPair">+ Ajouter une paire</button>
|
||||||
|
${forgeDocDeleteButtonHtml()}
|
||||||
|
`;
|
||||||
|
|
||||||
|
panel.querySelectorAll('#docAssocColor .docSwatch').forEach((sw) => {
|
||||||
|
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('docAssocAddPair').addEventListener('click', () => {
|
||||||
|
patch({ pairs: [...pairs, forgeDocAssociationNewPair()] });
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll('#docAssocPairsList .docQuizRemoveBtn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const pIndex = Number(btn.dataset.pairIndex);
|
||||||
|
patch({ pairs: pairs.filter((_, i) => i !== pIndex) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
panel.querySelectorAll('.docAssocPairInput').forEach((input) => {
|
||||||
|
input.addEventListener('change', (e) => {
|
||||||
|
const pIndex = Number(input.dataset.pairIndex);
|
||||||
|
const { side } = input.dataset;
|
||||||
|
patch({ pairs: pairs.map((p, i) => (i === pIndex ? { ...p, [side]: e.target.value } : p)) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocRenderProps(el) {
|
function forgeDocRenderProps(el) {
|
||||||
const panel = document.getElementById('docPropsPanel');
|
const panel = document.getElementById('docPropsPanel');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -942,6 +1158,7 @@ function forgeDocRenderProps(el) {
|
|||||||
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
||||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||||
|
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||||
else forgeDocRenderMinigameProps(panel, el);
|
else forgeDocRenderMinigameProps(panel, el);
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Modèle de données du mini-jeu Association
|
||||||
|
(document_engine/labels/association_config.py) — sans Flask, teste
|
||||||
|
directement sanitize_association_config, et le rendu du plateau."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import document_engine as doc_engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_on_missing_input_returns_full_defaults() -> None:
|
||||||
|
assert doc_engine.sanitize_association_config(None) == {"theme_color": "#ff5f2e", "pairs": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_keeps_valid_pairs() -> None:
|
||||||
|
raw = {"theme_color": "#123456", "pairs": [{"left": "Chat", "right": "Un félin"}]}
|
||||||
|
config = doc_engine.sanitize_association_config(raw)
|
||||||
|
assert config["theme_color"] == "#123456"
|
||||||
|
assert config["pairs"] == [{"left": "Chat", "right": "Un félin"}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_drops_a_pair_missing_either_side() -> None:
|
||||||
|
raw = {"pairs": [{"left": "Chat", "right": " "}, {"left": " ", "right": "Un félin"}]}
|
||||||
|
assert doc_engine.sanitize_association_config(raw)["pairs"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_caps_pairs_at_max() -> None:
|
||||||
|
raw = {"pairs": [{"left": f"L{i}", "right": f"R{i}"} for i in range(doc_engine.MAX_PAIRS + 3)]}
|
||||||
|
config = doc_engine.sanitize_association_config(raw)
|
||||||
|
assert len(config["pairs"]) == doc_engine.MAX_PAIRS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_association_config_ignores_garbage_top_level_input() -> None:
|
||||||
|
assert doc_engine.sanitize_association_config("n'importe quoi") == doc_engine.DEFAULT_ASSOCIATION_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def _association_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"id": 1, "kind": "association", "parent_id": None, "order_index": 0, "attributes": attributes}
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_association_without_pairs_shows_only_the_badge() -> None:
|
||||||
|
html = doc_engine.render_document_element(_association_element(doc_engine.DEFAULT_ASSOCIATION_CONFIG), {})
|
||||||
|
assert "0 paire" in html
|
||||||
|
assert "docAssocPlayer" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_association_with_pairs_includes_the_interactive_board() -> None:
|
||||||
|
config = doc_engine.sanitize_association_config(
|
||||||
|
{"pairs": [{"left": "Chat", "right": "Un félin"}, {"left": "Chien", "right": "Un canidé"}]}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_association_element(config), {})
|
||||||
|
assert "docAssocPlayer" in html
|
||||||
|
assert "data-assoc-config=" in html
|
||||||
|
assert "2 paires" in html
|
||||||
|
# Les deux colonnes sont mélangées indépendamment côté rendu (voir
|
||||||
|
# _render_association_player) : impossible d'attendre un ORDRE précis,
|
||||||
|
# seulement que le contenu des deux côtés est bien présent quelque part.
|
||||||
|
assert "Chat" in html
|
||||||
|
assert "Un f\\u00e9lin" in html or "Un félin" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_association_escapes_pair_text_in_embedded_json() -> None:
|
||||||
|
config = doc_engine.sanitize_association_config(
|
||||||
|
{"pairs": [{"left": '"><script>alert(1)</script>', "right": "def"}, {"left": "abc", "right": "def2"}]}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document_element(_association_element(config), {})
|
||||||
|
assert "<script>alert(1)</script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
@@ -66,7 +66,7 @@ def test_document_element_update_persists_attributes(client: FlaskClient, suppor
|
|||||||
|
|
||||||
|
|
||||||
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
|
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
|
||||||
"""Le quiz est le seul kind revalidé côté serveur (voir
|
"""Quiz et Association sont revalidés côté serveur (voir
|
||||||
routes/document/document_element_update.py) : un choix en trop
|
routes/document/document_element_update.py) : un choix en trop
|
||||||
(5e) doit être tronqué, et la réponse renvoie les attributs
|
(5e) doit être tronqué, et la réponse renvoie les attributs
|
||||||
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
|
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
|
||||||
@@ -90,7 +90,27 @@ def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskCli
|
|||||||
assert payload["ok"] is True
|
assert payload["ok"] is True
|
||||||
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
|
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
|
||||||
assert "1 question" in payload["rendered_html"]
|
assert "1 question" in payload["rendered_html"]
|
||||||
assert "10 points" in payload["rendered_html"]
|
|
||||||
|
|
||||||
|
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
|
||||||
|
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "association"})
|
||||||
|
element_id = add_resp.get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/update",
|
||||||
|
json={
|
||||||
|
"theme_color": "#ffb020",
|
||||||
|
"pairs": [
|
||||||
|
{"left": "Chat", "right": "Un félin"},
|
||||||
|
{"left": "Sans correspondance", "right": ""},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["attributes"]["pairs"] == [{"left": "Chat", "right": "Un félin"}]
|
||||||
|
assert "1 paire" in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user