Compare commits

...
2 Commits
Author SHA1 Message Date
williamandClaude Sonnet 5 081b65f48f Corrige "+ Ajouter une paire" qui ne faisait rien (Association)
Build and deploy / test-python (push) Failing after 1m5s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / lint-python (push) Failing after 1m5s
Build and deploy / lint-js (push) Failing after 1m6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m0s
Retour utilisateur direct : cliquer "+ Ajouter une paire" ne montrait
aucune nouvelle ligne. Cause reelle : la nouvelle paire etait creee
avec ses deux champs vides ({left:'', right:''}), or
sanitize_association_config (routes/document/document_element_update.py)
rejette a raison toute paire dont un cote est vide - la paire
disparaissait donc silencieusement des sa creation, sans aucun signal.
Corrige en pre-remplissant des valeurs par defaut non vides ("Nouvel
element" / "Sa correspondance"), meme strategie que
forgeDocQuizNewQuestion (deja correcte pour le quiz).

Verifie via sanitize_association_config directement (la paire par
defaut survit desormais) et via un appel HTTP complet reproduisant
exactement l'action du bouton cote client (element cree, persiste,
rendu dans le badge "1 paire").

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict tous verts ; 50 tests document verifies frais.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:07:54 +02:00
williamandClaude Sonnet 5 ce750ec697 Implemente le mini-jeu Association (glisser-deposer par paires)
document_engine/labels/association_config.py (nouveau) : modele de
donnees, meme convention resolve_X/sanitize_X que quiz_config.py -
DEFAULT_ASSOCIATION_CONFIG, sanitize_association_config (chaque paire
doit avoir ses deux cotes non vides, sinon supprimee silencieusement ;
liste tronquee a MAX_PAIRS=8).

Cote serveur, routes/document/document_element_update.py revalide
desormais aussi l'association avant persistance (meme raisonnement que
pour le quiz). Le rendu (_render_association) affiche un resume reel
(nombre de paires) et, des qu'au moins une paire existe, un plateau de
glisser-deposer REELEMENT interactif en Mode Apercu
(_render_association_player) : les deux colonnes (termes/
correspondances) sont melangees independamment (random.shuffle,
melange d'affichage documente dans CODE_QUALITY.md) puis embarquees en
JSON dans un attribut data-assoc-config.

Cote editeur, le panneau Proprietes d'une association ("relier
visuellement deux champs qui vont ensemble") est une liste de paires
repetable, chaque ligne reliant visuellement un champ "Element" et un
champ "Correspondance" par un glyphe ↔. Le plateau jouable en Apercu
(static/document/js/document-editor.js) supporte deux facons de jouer,
toutes deux reelles : glisser-deposer HTML5 natif, ou cliquer une
carte puis son emplacement (repli pour les appareils sans support
fiable du drag) - bonne association verrouillee en vert, mauvaise
signalee puis reinitialisee, ecran de resultat une fois toutes les
paires associees.

Bug reel trouve ET corrige via simulation DOM complete (glisser-
deposer + clic simules, pas juste un chargement de page) : le
feedback visuel reutilisait la classe CSS du quiz via une
reaffectation de className qui supprimait au passage la classe
d'identite docAssocFeedback, rendant l'element introuvable des le
premier essai de match (aurait plante en usage reel des la premiere
tentative). Corrige en gardant toujours les deux classes ensemble.

Tests : 8 tests purs (tests/document/test_association_config.py, sans
Flask) + 1 test de route verifiant la sanitization a l'ecriture.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 50 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur paire invalide, rendu du plateau,
et simulation DOM du gameplay reel (glisser-deposer correct/incorrect,
clic-selection, progression, ecran de resultat) - script de
diagnostic non conserve dans le depot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 13:01:21 +02:00
12 changed files with 638 additions and 33 deletions
+1 -1
View File
@@ -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 |
+10
View File
@@ -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 {}
+36 -6
View File
@@ -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,
+12 -2
View File
@@ -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.
+9 -7
View File
@@ -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),
+150 -11
View File
@@ -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;
}
} }
+224
View File
@@ -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');
+67
View File
@@ -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 "&lt;script&gt;" in html
+22 -2
View File
@@ -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: