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 |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
|
||||
@@ -27,6 +27,12 @@ from .elements.get_document_element import get_document_element
|
||||
from .elements.list_document_elements import list_document_elements
|
||||
from .elements.move_document_element import move_document_element
|
||||
from .elements.update_document_element_attributes import update_document_element_attributes
|
||||
from .labels.association_config import (
|
||||
DEFAULT_ASSOCIATION_CONFIG,
|
||||
MAX_PAIRS,
|
||||
MIN_PAIRS,
|
||||
sanitize_association_config,
|
||||
)
|
||||
from .labels.element_kind_labels import (
|
||||
CONTENT_KINDS,
|
||||
ELEMENT_KIND_LABELS,
|
||||
@@ -48,12 +54,15 @@ from .rendering.render_document_element import render_document, render_document_
|
||||
|
||||
__all__ = [
|
||||
"CONTENT_KINDS",
|
||||
"DEFAULT_ASSOCIATION_CONFIG",
|
||||
"DEFAULT_QUIZ_CONFIG",
|
||||
"ELEMENT_KIND_LABELS",
|
||||
"ELEMENT_LIBRARY",
|
||||
"MAX_CHOICES",
|
||||
"MAX_PAIRS",
|
||||
"MAX_TIMER_SECONDS",
|
||||
"MIN_CHOICES",
|
||||
"MIN_PAIRS",
|
||||
"MIN_TIMER_SECONDS",
|
||||
"MINIGAME_KINDS",
|
||||
"SHAPE_KINDS",
|
||||
@@ -66,6 +75,7 @@ __all__ = [
|
||||
"quiz_total_points",
|
||||
"render_document",
|
||||
"render_document_element",
|
||||
"sanitize_association_config",
|
||||
"sanitize_quiz_config",
|
||||
"update_document_element_attributes",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Modèle de données du mini-jeu Association (voir docs/plan/PLAN.md §3.2)
|
||||
— l'apprenant relie chaque carte de gauche à sa correspondance de droite
|
||||
par glisser-déposer (voir document_engine/rendering/render_document_element.py
|
||||
::_render_association_player). Même convention resolve_X/sanitize_X que
|
||||
quiz_config.py et game_engine/rendering/quiz_box_config.py côté jeu
|
||||
(aucun import croisé)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
MIN_PAIRS = 2
|
||||
MAX_PAIRS = 8
|
||||
|
||||
DEFAULT_ASSOCIATION_CONFIG: dict[str, Any] = {
|
||||
"theme_color": "#ff5f2e",
|
||||
"pairs": [],
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_pair(raw: Any) -> dict[str, str] | None:
|
||||
"""None si la paire est invalide (un des deux côtés vide) — filtrée par
|
||||
sanitize_association_config plutôt que de faire échouer tout le
|
||||
mini-jeu, même convention que quiz_config.py::_sanitize_question."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
left = str(raw.get("left", "")).strip()
|
||||
right = str(raw.get("right", "")).strip()
|
||||
if not left or not right:
|
||||
return None
|
||||
return {"left": left, "right": right}
|
||||
|
||||
|
||||
def sanitize_association_config(raw_config: Any) -> dict[str, Any]:
|
||||
config = dict(DEFAULT_ASSOCIATION_CONFIG)
|
||||
if not isinstance(raw_config, dict):
|
||||
return config
|
||||
theme_color = raw_config.get("theme_color")
|
||||
if isinstance(theme_color, str) and theme_color:
|
||||
config["theme_color"] = theme_color
|
||||
raw_pairs = raw_config.get("pairs")
|
||||
if isinstance(raw_pairs, list):
|
||||
pairs = [p for p in (_sanitize_pair(item) for item in raw_pairs) if p is not None]
|
||||
config["pairs"] = pairs[:MAX_PAIRS]
|
||||
return config
|
||||
@@ -5,6 +5,7 @@ défaut posés à la création de chaque type."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .association_config import DEFAULT_ASSOCIATION_CONFIG
|
||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||
|
||||
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
||||
@@ -77,17 +78,19 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
||||
if kind == "row":
|
||||
return {"gap": 16, "align": "stretch", "justify": "flex-start"}
|
||||
if kind == "quiz":
|
||||
# Seul mini-jeu implémenté pour l'instant (voir quiz_config.py) —
|
||||
# les autres restent un simple emplacement réservé ci-dessous.
|
||||
# Copie de "questions" (liste, jamais un simple dict(...) qui la
|
||||
# partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant
|
||||
# ne la mute en place aujourd'hui, mais la copier ici coûte rien et
|
||||
# évite d'ancrer cette hypothèse fragile pour la suite.
|
||||
return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])}
|
||||
if kind == "association":
|
||||
# Même raison de copie que "quiz" ci-dessus (voir association_config.py).
|
||||
return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])}
|
||||
if kind in MINIGAME_KINDS:
|
||||
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
||||
# paragraphe : "état par défaut en attendant sa spécification") —
|
||||
# décision actée : cœur complet + mini-jeux en emplacement
|
||||
# réservé, formulaires de contenu dédiés = chantier séparé.
|
||||
# réservé (memory/mots/scenario/zones), formulaires de contenu
|
||||
# dédiés = chantier séparé.
|
||||
return {"theme_color": "#ff5f2e"}
|
||||
return {}
|
||||
|
||||
@@ -13,10 +13,11 @@ toujours en position absolue (`parent_id = NULL`).
|
||||
être top-niveau ou enfants d'une rangée.
|
||||
|
||||
## `MINIGAME_KINDS: tuple[str, ...]`
|
||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` — seul
|
||||
`"quiz"` est implémenté (voir `quiz_config.py` ci-dessous) ; les 5 autres
|
||||
gardent un panneau Propriétés minimal (emplacement réservé), formulaires
|
||||
de contenu dédiés hors périmètre de cette passe.
|
||||
`("quiz", "association", "memory", "mots", "scenario", "zones")` — `"quiz"`
|
||||
et `"association"` sont implémentés (voir `quiz_config.py`/
|
||||
`association_config.py` ci-dessous) ; les 4 autres gardent un panneau
|
||||
Propriétés minimal (emplacement réservé), formulaires de contenu dédiés
|
||||
hors périmètre de cette passe.
|
||||
|
||||
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
|
||||
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
|
||||
@@ -36,8 +37,9 @@ Attributs posés à la création d'un élément de ce type (voir
|
||||
formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`),
|
||||
texte (`content/style` + `bold/italic/underline/align/color`),
|
||||
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
|
||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), autre mini-jeu
|
||||
(`theme_color`), ou `{}` pour un `kind` inconnu.
|
||||
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
|
||||
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), autre
|
||||
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
|
||||
- **Exceptions** : aucune.
|
||||
|
||||
## `quiz_config.py` — modèle de données du mini-jeu Quiz
|
||||
@@ -72,3 +74,31 @@ mirroir de `db/dialogue_lines.py::sum_question_rewards` côté jeu, utile
|
||||
le jour où un export calculera un score maximum.
|
||||
- **Retour** : entier ≥ 0.
|
||||
- **Exceptions** : aucune.
|
||||
|
||||
## `association_config.py` — modèle de données du mini-jeu Association
|
||||
|
||||
Deuxième mini-jeu implémenté : l'apprenant relie chaque carte de gauche
|
||||
("terme") à sa correspondance de droite ("définition") par glisser-déposer
|
||||
(voir `document_engine/rendering/render_document_element.py`::
|
||||
`_render_association_player`). Même convention que `quiz_config.py`.
|
||||
|
||||
### `MIN_PAIRS`, `MAX_PAIRS: int`
|
||||
Bornes de validation (`2`/`8` paires). `MIN_PAIRS` n'est pas imposé par
|
||||
`sanitize_association_config` (une seule paire valide reste acceptée,
|
||||
comme un quiz à une seule question) — c'est une recommandation pour le
|
||||
panneau Propriétés, pas une contrainte technique du rendu.
|
||||
|
||||
### `DEFAULT_ASSOCIATION_CONFIG: dict[str, Any]`
|
||||
`{"theme_color": "#ff5f2e", "pairs": []}`.
|
||||
|
||||
### `sanitize_association_config(raw_config: Any) -> dict[str, Any]`
|
||||
Valide/nettoie une config d'association arbitraire (JSON venu du client)
|
||||
— jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
|
||||
`DEFAULT_ASSOCIATION_CONFIG`. Chaque paire de `raw_config["pairs"]` est
|
||||
validée indépendamment (voir `_sanitize_pair`, privée) : les deux côtés
|
||||
(`left`/`right`) doivent être non vides une fois `.strip()`-és, sinon la
|
||||
paire entière est silencieusement supprimée de la liste (jamais une
|
||||
levée qui ferait échouer tout le reste du mini-jeu). La liste finale est
|
||||
tronquée à `MAX_PAIRS`.
|
||||
- **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`).
|
||||
- **Exceptions** : aucune.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import html as html_lib
|
||||
import json
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
|
||||
@@ -193,6 +194,62 @@ def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
||||
)
|
||||
|
||||
|
||||
def _render_association_player(config: dict[str, Any]) -> str:
|
||||
"""Plateau de glisser-déposer RÉELLEMENT interactif — affiché
|
||||
uniquement en Mode Aperçu, même principe que _render_quiz_player :
|
||||
aucun aller-retour serveur, tout le déroulé (glisser une carte de
|
||||
gauche sur son emplacement de droite, ou cliquer les deux) est géré
|
||||
par static/document/js/document-editor.js à partir du JSON embarqué.
|
||||
Les deux colonnes sont mélangées INDÉPENDAMMENT (sinon la position
|
||||
suffirait à deviner l'association, sans avoir à lire quoi que ce
|
||||
soit) — random.shuffle : mélange d'affichage pour un mini-jeu,
|
||||
jamais un usage cryptographique (voir CODE_QUALITY.md)."""
|
||||
pairs = config["pairs"]
|
||||
left_items = [{"pair_index": i, "text": p["left"]} for i, p in enumerate(pairs)]
|
||||
right_items = [{"pair_index": i, "text": p["right"]} for i, p in enumerate(pairs)]
|
||||
random.shuffle(left_items) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
|
||||
random.shuffle(right_items) # NOSONAR python:S2245 - idem
|
||||
config_json = html_lib.escape(json.dumps({"left": left_items, "right": right_items}), quote=True)
|
||||
return (
|
||||
f'<div class="docAssocPlayer" data-assoc-config="{config_json}">'
|
||||
f'<div class="docAssocCard">'
|
||||
f'<div class="docQuizKicker">Association</div>'
|
||||
f'<div class="docAssocTitle">Associez chaque élément à sa correspondance</div>'
|
||||
f'<div class="docAssocMeta"><span class="docAssocProg"></span></div>'
|
||||
f'<div class="docAssocBoard">'
|
||||
f'<div class="docAssocColumn docAssocColumnLeft"></div>'
|
||||
f'<div class="docAssocColumn docAssocColumnRight"></div>'
|
||||
f"</div>"
|
||||
f'<div class="docAssocFeedback"></div>'
|
||||
f"</div>"
|
||||
f'<div class="docAssocResultCard" style="display:none;">'
|
||||
f'<div class="docAssocResultBig">✓</div>'
|
||||
f'<div class="docAssocResultSub">Toutes les paires sont associées !</div>'
|
||||
f'<button type="button" class="docAssocRestartBtn">Recommencer</button>'
|
||||
f"</div></div>"
|
||||
)
|
||||
|
||||
|
||||
def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
from ..labels.association_config import sanitize_association_config
|
||||
|
||||
config = sanitize_association_config(el["attributes"])
|
||||
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||
pair_count = len(config["pairs"])
|
||||
pair_label = "paire" if pair_count <= 1 else "paires"
|
||||
player_html = _render_association_player(config) if config["pairs"] else ""
|
||||
return (
|
||||
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="association" '
|
||||
f'style="border-color:{theme_color};">'
|
||||
f'<div class="docMinigameBadge">'
|
||||
f'<span class="docMinigameLabel">Association</span>'
|
||||
f'<span class="docMinigamePlaceholder">{pair_count} {pair_label}</span>'
|
||||
f"</div>"
|
||||
f"{player_html}"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
||||
|
||||
@@ -208,7 +265,7 @@ _RENDERERS = {
|
||||
"image": _render_image,
|
||||
"bouton": _render_button,
|
||||
"quiz": _render_quiz,
|
||||
"association": _render_minigame_placeholder,
|
||||
"association": _render_association,
|
||||
"memory": _render_minigame_placeholder,
|
||||
"mots": _render_minigame_placeholder,
|
||||
"scenario": _render_minigame_placeholder,
|
||||
|
||||
@@ -48,7 +48,17 @@ regroupement à chaque appel.
|
||||
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
|
||||
lit cet attribut et gère tout le déroulé (réponse/score/question
|
||||
suivante/résultat) côté client, sans aucun aller-retour serveur.
|
||||
- **Autres mini-jeux** (`association`/`memory`/`mots`/`scenario`/`zones`) :
|
||||
carte placeholder portant le libellé du type (voir
|
||||
- **Association** : toujours une carte résumant la config réelle (nombre
|
||||
de paires) — sanitizée (`sanitize_association_config`) avant lecture.
|
||||
Si au moins une paire existe, s'y ajoute (fonction privée
|
||||
`_render_association_player`) le plateau de glisser-déposer RÉEL et
|
||||
interactif affiché en Mode Aperçu, masqué en édition par CSS
|
||||
(`.docAssocPlayer`) : les deux colonnes (termes/correspondances) sont
|
||||
mélangées INDÉPENDAMMENT (`random.shuffle`, mélange d'affichage — voir
|
||||
`CODE_QUALITY.md`) puis embarquées en JSON dans un attribut
|
||||
`data-assoc-config`, échappé pour l'HTML — même principe que le Quiz,
|
||||
aucun aller-retour serveur pendant qu'on joue.
|
||||
- **Autres mini-jeux** (`memory`/`mots`/`scenario`/`zones`) : carte
|
||||
placeholder portant le libellé du type (voir
|
||||
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
|
||||
formulaire de contenu dédié hors périmètre de cette passe.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -942,6 +1165,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');
|
||||
|
||||
@@ -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:
|
||||
"""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:
|
||||
|
||||
Reference in New Issue
Block a user