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: