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
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 .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 {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'<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:
|
||||
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,
|
||||
"bouton": _render_button,
|
||||
"quiz": _render_quiz,
|
||||
"association": _render_minigame_placeholder,
|
||||
"association": _render_association,
|
||||
"memory": _render_minigame_placeholder,
|
||||
"mots": _render_minigame_placeholder,
|
||||
"scenario": _render_minigame_placeholder,
|
||||
|
||||
@@ -48,7 +48,17 @@ regroupement à chaque appel.
|
||||
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
|
||||
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.
|
||||
- **Autres mini-jeux** (`association`/`memory`/`mots`/`scenario`/`zones`) :
|
||||
carte placeholder portant le libellé du type (voir
|
||||
- **Association** : toujours une carte résumant la config réelle (nombre
|
||||
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é,
|
||||
formulaire de contenu dédié hors périmètre de cette passe.
|
||||
|
||||
Reference in New Issue
Block a user