Compare commits
2
Commits
9fb22be1ae
...
081b65f48f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
081b65f48f | ||
|
|
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,86 @@ function forgeDocRenderQuizProps(panel, el) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocAssociationNewPair() {
|
||||||
|
// Champs pré-remplis (jamais vides) : sanitize_association_config
|
||||||
|
// (routes/document/document_element_update.py) rejette silencieusement
|
||||||
|
// toute paire dont un des deux côtés est vide — une paire vide envoyée
|
||||||
|
// telle quelle disparaîtrait donc aussitôt créée, sans aucun signal
|
||||||
|
// pour le créateur (bug réel trouvé le 20/09/2026 : "+ Ajouter une
|
||||||
|
// paire" semblait ne rien faire). Même stratégie que
|
||||||
|
// forgeDocQuizNewQuestion ci-dessus.
|
||||||
|
return { left: 'Nouvel élément', right: 'Sa correspondance' };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +1165,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