From ce750ec6979531dc480f15ab22c90fe55950c5ce Mon Sep 17 00:00:00 2001 From: william Date: Sun, 20 Sep 2026 13:01:21 +0200 Subject: [PATCH] Implemente le mini-jeu Association (glisser-deposer par paires) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CODE_QUALITY.md | 2 +- document_engine/__init__.py | 10 + document_engine/labels/association_config.py | 43 ++++ document_engine/labels/element_kind_labels.py | 9 +- document_engine/labels/labels.md | 42 +++- .../rendering/render_document_element.py | 59 ++++- document_engine/rendering/rendering.md | 14 +- routes/document/document_element_update.py | 16 +- static/document/document-editor.css | 161 ++++++++++++- static/document/js/document-editor.js | 217 ++++++++++++++++++ tests/document/test_association_config.py | 67 ++++++ tests/document/test_document_routes.py | 24 +- 12 files changed, 631 insertions(+), 33 deletions(-) create mode 100644 document_engine/labels/association_config.py create mode 100644 tests/document/test_association_config.py 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'
' + f'
' + f'
Association
' + f'
Associez chaque élément à sa correspondance
' + f'
' + f'
' + f'
' + f'
' + f"
" + f'
' + f"
" + f'
" + ) + + +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'
' + f'
' + f'Association' + f'{pair_count} {pair_label}' + f"
" + f"{player_html}" + f"
" + ) + + def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: return f'
Type inconnu : {html_lib.escape(el["kind"])}
' @@ -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, diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index e7e188e7..14a23ed1 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -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. diff --git a/routes/document/document_element_update.py b/routes/document/document_element_update.py index 8b4d6710..e8135768 100644 --- a/routes/document/document_element_update.py +++ b/routes/document/document_element_update.py @@ -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 systématiquement l'état complet de ses champs. - Un quiz est le seul kind revalidé côté serveur (sanitize_quiz_config, - même convention que game_engine/rendering/quiz_box_config.py côté - jeu) : sa forme (liste de questions/choix/index de bonne réponse) doit - rester structurellement correcte pour que le rendu ne plante jamais, - contrairement aux autres kinds (texte/forme/image...) dont les - attributs sont de simples valeurs scalaires sans structure à - garantir.""" + Quiz et Association sont les seuls kinds revalidés côté serveur + (sanitize_quiz_config/sanitize_association_config, même convention que + game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste + de questions/choix, liste de paires) doit rester structurellement + correcte pour que le rendu ne plante jamais, contrairement aux autres + kinds (texte/forme/image...) dont les attributs sont de simples + valeurs scalaires sans structure à garantir.""" element = document_engine.get_document_element(slug, element_id) if element is None: return jsonify({"error": "élément introuvable"}), 404 attributes: dict[str, Any] = request.get_json(force=True) or {} if element["kind"] == "quiz": 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) element = db.assert_not_none( document_engine.get_document_element(slug, element_id), diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 606f1c76..9407fcd0 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -500,33 +500,39 @@ img.docImage { color: var(--doc-muted); } -/* ---- Quiz : questionnaire interactif en Mode Aperçu (voir docs/plan/ - maquettes/document-formation-web.html — référence visuelle). Masqué en - édition (seul .docMinigameBadge y est visible, voir plus haut) ; les - deux jamais visibles en même temps. ---- */ -.docQuizPlayer { +/* ---- Quiz/Association : mini-jeux interactifs en Mode Aperçu (voir + docs/plan/maquettes/document-formation-web.html — référence visuelle + du Quiz). Masqués en édition (seul .docMinigameBadge y est visible, + voir plus haut) ; jamais les deux visibles en même temps. ---- */ +.docQuizPlayer, +.docAssocPlayer { 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; } -.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer { +.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer, +.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer { display: block; } -.docEditor3.docEditor3--preview [data-kind="quiz"] { +.docEditor3.docEditor3--preview [data-kind="quiz"], +.docEditor3.docEditor3--preview [data-kind="association"] { border: none; background: transparent; } .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] (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 - RÉELLEMENT cliquable (répondre, question suivante, recommencer). */ + l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent + RÉELLEMENT cliquables/glissables. */ pointer-events: auto; } @@ -731,6 +737,135 @@ img.docImage { 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 { font-size: 12.5px; color: var(--doc-muted); @@ -1119,4 +1254,8 @@ img.docImage { .docBottomNav { display: flex; } + + .docAssocBoard { + flex-direction: column; + } } diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 0f8336d1..3079002c 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -476,6 +476,7 @@ function forgeDocBindCanvasInteractions() { } }); 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) { document.querySelectorAll('#docBottomNav button').forEach((b) => { 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 ` +
+ + + + +
+ `; +} + +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')} +
+ Couleur du thème +
+ ${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `
`).join('')} +
+
+
Paires à relier
+
${pairs.length ? pairs.map(forgeDocRenderAssociationPairHtml).join('') : '
Aucune paire — ajoute la première ci-dessous.
'}
+ + ${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) { const panel = document.getElementById('docPropsPanel'); if (!el) { @@ -942,6 +1158,7 @@ function forgeDocRenderProps(el) { else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el); else if (el.kind === 'row') forgeDocRenderRowProps(panel, el); else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el); + else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el); else forgeDocRenderMinigameProps(panel, el); const deleteBtn = document.getElementById('docDeletePropBtn'); diff --git a/tests/document/test_association_config.py b/tests/document/test_association_config.py new file mode 100644 index 00000000..41d62fe3 --- /dev/null +++ b/tests/document/test_association_config.py @@ -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": '">', "right": "def"}, {"left": "abc", "right": "def2"}]} + ) + html = doc_engine.render_document_element(_association_element(config), {}) + assert "" not in html + assert "<script>" in html diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index 174e2282..fd13ad69 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -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: - """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 (5e) doit être tronqué, et la réponse renvoie les attributs 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["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"] 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: