Implemente le mini-jeu Memory (retournement de cartes, mode paire/single)

document_engine/labels/memory_config.py (nouveau) : modele de donnees,
meme convention resolve_X/sanitize_X que quiz_config.py/
association_config.py - DEFAULT_MEMORY_CONFIG, sanitize_memory_config.
Chaque carte a un recto ET un verso, chacun avec image/texte
independants et tous deux optionnels ; seul le verso doit avoir au
moins l'un des deux non vide (rien a reveler/apparier sinon) - le
recto peut rester entierement vide (dos de carte generique "?" par
defaut). Liste tronquee a MAX_CARDS=8.

Cote serveur, routes/document/document_element_update.py revalide
desormais aussi memory avant persistance. Le rendu (_render_memory)
affiche un resume reel (nombre de cartes, mode) et, des qu'au moins
une carte existe, un plateau de retournement REELEMENT interactif en
Mode Apercu (_render_memory_player) : en mode "paire", chaque carte
definie est DUPLIQUEE en deux instances partageant le meme card_index
(appariement classique) ; en mode "single", une seule instance par
carte (simple retournement, sans appariement - "c'est donc un
retourner de carte classique plus un jeu memory"). Les instances sont
melangees (random.shuffle, documente dans CODE_QUALITY.md) puis
embarquees en JSON dans data-memory-config.

Cote editeur, le panneau Proprietes propose un bascule segmentee
Paire/Simple et une liste de cartes repetable, chaque carte avec ses
deux faces (recto/verso) editables independamment (image + texte).
Extrait au passage forgeDocEscapeHtml (ex-forgeDocEscapeForTextarea,
generalise pour couvrir aussi les attributs) reutilise pour les deux
mini-jeux. Le plateau jouable (static/document/js/document-editor.js)
est une vraie carte-retournement CSS 3D (perspective/rotateY), contenu
de chaque face construit via DOM (textContent/img.src, jamais
innerHTML avec le texte du createur - meme precaution que le plateau
Association) : bon appariement verrouille en vert, mauvais reinitialise
apres un delai, ecran de resultat une fois le jeu termine (les deux
modes), et un "Recommencer" qui remelange reellement les cartes
(Fisher-Yates cote client).

Tests : 11 tests purs (tests/document/test_memory_config.py, sans
Flask, dont un qui verifie explicitement la duplication en mode paire
vs son absence en mode single) + 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 ; 62 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur carte invalide, rendu du plateau,
et simulation DOM du gameplay reel dans les DEUX modes (mode paire :
mauvais appariement puis bon appariement puis jeu complet ; mode
single : retournement puis jeu complet) - script de diagnostic non
conserve dans le depot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 14:13:28 +02:00
co-authored by Claude Sonnet 5
parent cf46da3388
commit 4111081e1a
12 changed files with 732 additions and 39 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`, `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 | | `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` (×3 : `_render_association_player` ×2, `_render_memory_player` ×1 — ajoutés 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, mélange des cartes du mini-jeu Memory) — 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 |
+12
View File
@@ -41,6 +41,13 @@ from .labels.element_kind_labels import (
SHAPE_KINDS, SHAPE_KINDS,
element_default_attributes, element_default_attributes,
) )
from .labels.memory_config import (
CARD_MODES,
DEFAULT_MEMORY_CONFIG,
MAX_CARDS,
MIN_CARDS,
sanitize_memory_config,
)
from .labels.quiz_config import ( from .labels.quiz_config import (
DEFAULT_QUIZ_CONFIG, DEFAULT_QUIZ_CONFIG,
MAX_CHOICES, MAX_CHOICES,
@@ -53,14 +60,18 @@ from .labels.quiz_config import (
from .rendering.render_document_element import render_document, render_document_element from .rendering.render_document_element import render_document, render_document_element
__all__ = [ __all__ = [
"CARD_MODES",
"CONTENT_KINDS", "CONTENT_KINDS",
"DEFAULT_ASSOCIATION_CONFIG", "DEFAULT_ASSOCIATION_CONFIG",
"DEFAULT_MEMORY_CONFIG",
"DEFAULT_QUIZ_CONFIG", "DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS", "ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY", "ELEMENT_LIBRARY",
"MAX_CARDS",
"MAX_CHOICES", "MAX_CHOICES",
"MAX_PAIRS", "MAX_PAIRS",
"MAX_TIMER_SECONDS", "MAX_TIMER_SECONDS",
"MIN_CARDS",
"MIN_CHOICES", "MIN_CHOICES",
"MIN_PAIRS", "MIN_PAIRS",
"MIN_TIMER_SECONDS", "MIN_TIMER_SECONDS",
@@ -76,6 +87,7 @@ __all__ = [
"render_document", "render_document",
"render_document_element", "render_document_element",
"sanitize_association_config", "sanitize_association_config",
"sanitize_memory_config",
"sanitize_quiz_config", "sanitize_quiz_config",
"update_document_element_attributes", "update_document_element_attributes",
] ]
@@ -6,6 +6,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 .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_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")
@@ -86,11 +87,14 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
if kind == "association": if kind == "association":
# Même raison de copie que "quiz" ci-dessus (voir association_config.py). # Même raison de copie que "quiz" ci-dessus (voir association_config.py).
return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])} return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])}
if kind == "memory":
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
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é (memory/mots/scenario/zones), formulaires de contenu # réservé (mots/scenario/zones), formulaires de contenu dédiés =
# dédiés = chantier séparé. # chantier séparé.
return {"theme_color": "#ff5f2e"} return {"theme_color": "#ff5f2e"}
return {} return {}
+44 -7
View File
@@ -13,11 +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")` — `"quiz"` `("quiz", "association", "memory", "mots", "scenario", "zones")` —
et `"association"` sont implémentés (voir `quiz_config.py`/ `"quiz"`, `"association"` et `"memory"` sont implémentés (voir
`association_config.py` ci-dessous) ; les 4 autres gardent un panneau `quiz_config.py`/`association_config.py`/`memory_config.py` ci-dessous) ;
Propriétés minimal (emplacement réservé), formulaires de contenu dédiés les 3 autres gardent un panneau Propriétés minimal (emplacement réservé),
hors périmètre de cette passe. 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
@@ -38,8 +38,9 @@ Attributs posés à la création d'un élément de ce type (voir
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`), association quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), autre (`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu. (`DEFAULT_MEMORY_CONFIG`, voir `memory_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
@@ -102,3 +103,39 @@ levée qui ferait échouer tout le reste du mini-jeu). La liste finale est
tronquée à `MAX_PAIRS`. tronquée à `MAX_PAIRS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`). - **Retour** : dict complet (mêmes clés que `DEFAULT_ASSOCIATION_CONFIG`).
- **Exceptions** : aucune. - **Exceptions** : aucune.
## `memory_config.py` — modèle de données du mini-jeu Memory
Troisième mini-jeu implémenté : l'apprenant retourne des cartes pour
constituer des paires identiques (mode `"paire"`) ou simplement révéler
chaque carte une fois (mode `"single"`, sans appariement — voir
`document_engine/rendering/render_document_element.py`::
`_render_memory_player`). Même convention que `quiz_config.py`.
### `MIN_CARDS`, `MAX_CARDS: int`
Bornes de validation (`2`/`8` cartes DÉFINIES par le créateur — en mode
`"paire"`, le plateau affiche le double, chaque carte étant dupliquée).
`MIN_CARDS` n'est pas imposé par `sanitize_memory_config` (même logique
que `MIN_PAIRS` côté Association) — recommandation pour le panneau
Propriétés, pas une contrainte technique du rendu.
### `CARD_MODES: tuple[str, ...]`
`("paire", "single")`.
### `DEFAULT_MEMORY_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "mode": "paire", "cards": []}`.
### `sanitize_memory_config(raw_config: Any) -> dict[str, Any]`
Valide/nettoie une config de memory arbitraire (JSON venu du client) —
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
`DEFAULT_MEMORY_CONFIG`. `mode` retombe sur `"paire"` s'il n'est pas dans
`CARD_MODES`. Chaque carte de `raw_config["cards"]` est validée
indépendamment (voir `_sanitize_card`/`_sanitize_card_face`, privées) :
chaque face (`recto`/`verso`) a un `image` et un `text` indépendants et
tous deux optionnels, MAIS le `verso` doit avoir au moins l'un des deux
non vide (rien à révéler/apparier sinon) — le `recto`, lui, peut rester
entièrement vide (dos de carte générique "?" par défaut côté rendu). Une
carte invalide est silencieusement supprimée de la liste. La liste finale
est tronquée à `MAX_CARDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
- **Exceptions** : aucune.
+60
View File
@@ -0,0 +1,60 @@
"""Modèle de données du mini-jeu Memory (voir docs/plan/PLAN.md §3.2) —
l'apprenant retourne des cartes pour constituer des paires identiques
(mode "paire") ou simplement révéler chaque carte une fois (mode
"single", un retournement classique sans appariement). Même convention
resolve_X/sanitize_X que quiz_config.py/association_config.py (aucun
import croisé)."""
from typing import Any
MIN_CARDS = 2
MAX_CARDS = 8
CARD_MODES = ("paire", "single")
DEFAULT_MODE = "paire"
DEFAULT_MEMORY_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"mode": DEFAULT_MODE,
"cards": [],
}
def _sanitize_card_face(raw: Any) -> dict[str, str]:
"""Une face de carte (recto ou verso) — image et texte tous deux
optionnels et indépendants (le créateur peut mettre l'un, l'autre, ou
les deux, voir docs/plan/PLAN.md)."""
if not isinstance(raw, dict):
return {"image": "", "text": ""}
return {
"image": str(raw.get("image", "")).strip(),
"text": str(raw.get("text", "")).strip(),
}
def _sanitize_card(raw: Any) -> dict[str, Any] | None:
"""None si la carte est invalide — un verso entièrement vide (ni image
ni texte) n'aurait rien à révéler/apparier, contrairement au recto qui
peut légitimement rester vide (dos de carte générique par défaut)."""
if not isinstance(raw, dict):
return None
recto = _sanitize_card_face(raw.get("recto"))
verso = _sanitize_card_face(raw.get("verso"))
if not verso["image"] and not verso["text"]:
return None
return {"recto": recto, "verso": verso}
def sanitize_memory_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_MEMORY_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
mode = raw_config.get("mode")
config["mode"] = mode if mode in CARD_MODES else DEFAULT_MODE
raw_cards = raw_config.get("cards")
if isinstance(raw_cards, list):
cards = [c for c in (_sanitize_card(item) for item in raw_cards) if c is not None]
config["cards"] = cards[:MAX_CARDS]
return config
@@ -250,6 +250,64 @@ def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None
) )
def _render_memory_player(config: dict[str, Any]) -> str:
"""Plateau de Memory RÉELLEMENT interactif — affiché uniquement en
Mode Aperçu, même principe que _render_quiz_player/
_render_association_player. En mode "paire", chaque carte définie par
le créateur est dupliquée en deux instances partageant le même
card_index (l'appariement se fait dessus) ; en mode "single", une
seule instance par carte (simple retournement, sans appariement).
Les instances sont mélangées une seule fois ici (jamais recalculées
à chaque rendu répété d'un même Aperçu, voir la remarque dans
static/document/js/document-editor.js sur la ré-init au
rafraîchissement du canevas) puis embarquées en JSON."""
cards = config["cards"]
mode = config["mode"]
instances = []
for i, card in enumerate(cards):
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
if mode == "paire":
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
random.shuffle(instances) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
config_json = html_lib.escape(json.dumps({"mode": mode, "cards": instances}), quote=True)
return (
f'<div class="docMemoryPlayer" data-memory-config="{config_json}">'
f'<div class="docMemoryCardWrap">'
f'<div class="docQuizKicker">Memory</div>'
f'<div class="docAssocTitle docMemoryTitle"></div>'
f'<div class="docAssocMeta"><span class="docMemoryProg"></span></div>'
f'<div class="docMemoryGrid"></div>'
f"</div>"
f'<div class="docAssocResultCard docMemoryResultCard" style="display:none;">'
f'<div class="docAssocResultBig">✓</div>'
f'<div class="docAssocResultSub docMemoryResultSub"></div>'
f'<button type="button" class="docAssocRestartBtn docMemoryRestartBtn">Recommencer</button>'
f"</div></div>"
)
def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
from ..labels.memory_config import sanitize_memory_config
config = sanitize_memory_config(el["attributes"])
theme_color = html_lib.escape(str(config["theme_color"]))
card_count = len(config["cards"])
card_label = "carte" if card_count <= 1 else "cartes"
mode_label = "mode paire" if config["mode"] == "paire" else "mode simple"
subtitle = f"{card_count} {card_label} · {mode_label}"
player_html = _render_memory_player(config) if config["cards"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="memory" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Memory</span>'
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</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>'
@@ -266,7 +324,7 @@ _RENDERERS = {
"bouton": _render_button, "bouton": _render_button,
"quiz": _render_quiz, "quiz": _render_quiz,
"association": _render_association, "association": _render_association,
"memory": _render_minigame_placeholder, "memory": _render_memory,
"mots": _render_minigame_placeholder, "mots": _render_minigame_placeholder,
"scenario": _render_minigame_placeholder, "scenario": _render_minigame_placeholder,
"zones": _render_minigame_placeholder, "zones": _render_minigame_placeholder,
+14 -2
View File
@@ -58,7 +58,19 @@ regroupement à chaque appel.
`CODE_QUALITY.md`) puis embarquées en JSON dans un attribut `CODE_QUALITY.md`) puis embarquées en JSON dans un attribut
`data-assoc-config`, échappé pour l'HTML — même principe que le Quiz, `data-assoc-config`, échappé pour l'HTML — même principe que le Quiz,
aucun aller-retour serveur pendant qu'on joue. aucun aller-retour serveur pendant qu'on joue.
- **Autres mini-jeux** (`memory`/`mots`/`scenario`/`zones`) : carte - **Memory** : toujours une carte résumant la config réelle (nombre de
placeholder portant le libellé du type (voir cartes définies, mode paire/simple) — sanitizée
(`sanitize_memory_config`) avant lecture. Si au moins une carte existe,
s'y ajoute (fonction privée `_render_memory_player`) le plateau de
retournement RÉEL et interactif : en mode `"paire"`, chaque carte
définie est DUPLIQUÉE en deux instances partageant le même
`card_index` (l'appariement se fait dessus, classique Memory) ; en mode
`"single"`, une seule instance par carte (simple retournement, sans
appariement). Les instances sont mélangées (`random.shuffle`, mélange
d'affichage — voir `CODE_QUALITY.md`) puis embarquées en JSON dans un
attribut `data-memory-config`, échappé pour l'HTML — même principe que
le Quiz/l'Association, aucun aller-retour serveur pendant qu'on joue.
- **Autres mini-jeux** (`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.
+10 -6
View File
@@ -14,13 +14,15 @@ 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.
Quiz et Association sont les seuls kinds revalidés côté serveur Quiz, Association et Memory sont les seuls kinds revalidés côté
(sanitize_quiz_config/sanitize_association_config, même convention que serveur (sanitize_quiz_config/sanitize_association_config/
sanitize_memory_config, même convention que
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste
de questions/choix, liste de paires) doit rester structurellement de questions/choix, liste de paires, liste de cartes) doit rester
correcte pour que le rendu ne plante jamais, contrairement aux autres structurellement correcte pour que le rendu ne plante jamais,
kinds (texte/forme/image...) dont les attributs sont de simples contrairement aux autres kinds (texte/forme/image...) dont les
valeurs scalaires sans structure à garantir.""" attributs sont de simples 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
@@ -29,6 +31,8 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
attributes = document_engine.sanitize_quiz_config(attributes) attributes = document_engine.sanitize_quiz_config(attributes)
elif element["kind"] == "association": elif element["kind"] == "association":
attributes = document_engine.sanitize_association_config(attributes) attributes = document_engine.sanitize_association_config(attributes)
elif element["kind"] == "memory":
attributes = document_engine.sanitize_memory_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),
+111 -9
View File
@@ -500,27 +500,31 @@ img.docImage {
color: var(--doc-muted); color: var(--doc-muted);
} }
/* ---- Quiz/Association : mini-jeux interactifs en Mode Aperçu (voir /* ---- Quiz/Association/Memory : mini-jeux interactifs en Mode Aperçu
docs/plan/maquettes/document-formation-web.html — référence visuelle (voir docs/plan/maquettes/document-formation-web.html — référence
du Quiz). Masqués en édition (seul .docMinigameBadge y est visible, visuelle du Quiz). Masqués en édition (seul .docMinigameBadge y est
voir plus haut) ; jamais les deux visibles en même temps. ---- */ visible, voir plus haut) ; jamais les deux visibles en même temps. ---- */
.docQuizPlayer, .docQuizPlayer,
.docAssocPlayer { .docAssocPlayer,
.docMemoryPlayer {
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 { .docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="memory"] .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 { .docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer {
display: block; display: block;
} }
.docEditor3.docEditor3--preview [data-kind="quiz"], .docEditor3.docEditor3--preview [data-kind="quiz"],
.docEditor3.docEditor3--preview [data-kind="association"] { .docEditor3.docEditor3--preview [data-kind="association"],
.docEditor3.docEditor3--preview [data-kind="memory"] {
border: none; border: none;
background: transparent; background: transparent;
} }
@@ -528,7 +532,9 @@ img.docImage {
.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.docEditor3--preview .docAssocPlayer * { .docEditor3.docEditor3--preview .docAssocPlayer *,
.docEditor3.docEditor3--preview .docMemoryPlayer,
.docEditor3.docEditor3--preview .docMemoryPlayer * {
/* .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 ces mini-jeux restent l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
@@ -881,6 +887,102 @@ img.docImage {
font-size: 15px; font-size: 15px;
} }
/* ---- Memory : panneau Propriétés — étiquette au-dessus de chaque champ
recto/verso (voir forgeDocRenderMemoryCardHtml). Réutilise
.docAssocPairRow/.docAssocPairInput (même carte, mêmes champs) plutôt
que de dupliquer leurs règles. ---- */
.docMemoryFaceLabel {
font-size: 11.5px;
font-weight: 700;
letter-spacing: 0.03em;
color: var(--doc-muted);
text-transform: uppercase;
margin-top: 4px;
}
/* ---- Memory : plateau de jeu (voir docs/plan/PLAN.md, "retourner des
cartes pour constituer des paires d'images identiques"). Réutilise
.docAssocTitle/.docAssocMeta/.docAssocResultCard (mêmes rôles visuels,
voir forgeDocRenderMemoryProps ci-dessus). ---- */
.docMemoryCardWrap {
background: linear-gradient(180deg, var(--doc-card), var(--doc-bg-2));
border: 1px solid var(--doc-border);
border-radius: 16px;
padding: 1.8rem;
}
.docMemoryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(90px, 1fr));
gap: 12px;
}
.docMemoryCard {
aspect-ratio: 3 / 4;
perspective: 700px;
cursor: pointer;
}
.docMemoryCardInner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.4s ease;
transform-style: preserve-3d;
}
.docMemoryCard.is-flipped .docMemoryCardInner {
transform: rotateY(180deg);
}
.docMemoryCard.is-matched .docMemoryCardInner {
opacity: 0.55;
}
.docMemoryCardFace {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 6px;
border-radius: 10px;
border: 1px solid var(--doc-border);
background: var(--doc-bg-2);
backface-visibility: hidden;
overflow: hidden;
text-align: center;
font-size: 12px;
color: var(--doc-text);
}
.docMemoryCardFace img {
max-width: 100%;
max-height: 60%;
object-fit: contain;
}
.docMemoryCardFront {
background: linear-gradient(135deg, var(--doc-accent), var(--doc-accent-2));
color: #14100c;
}
.docMemoryCardBackGlyph {
font-size: 26px;
font-weight: 800;
}
.docMemoryCardBack {
transform: rotateY(180deg);
border-color: var(--doc-accent);
}
.docMemoryCard.is-matched .docMemoryCardFace {
border-color: var(--doc-quiz-success-border);
}
.docUnknown { .docUnknown {
font-size: 12.5px; font-size: 12.5px;
color: var(--doc-muted); color: var(--doc-muted);
+296 -11
View File
@@ -477,6 +477,7 @@ function forgeDocBindCanvasInteractions() {
}); });
forgeDocInitQuizPlayers(); forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers(); forgeDocInitAssociationPlayers();
forgeDocInitMemoryPlayers();
} }
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
@@ -746,6 +747,174 @@ function forgeDocInitAssociationPlayers() {
}); });
} }
/* ---------------------------------------------------------------------
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
* mode "paire" (retrouver les deux cartes identiques, classique) ou
* "single" (retourner chaque carte une fois, sans appariement). Contenu
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
* avec le texte du créateur) — même précaution que le plateau Association.
* ------------------------------------------------------------------- */
function forgeDocMemoryPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
return {
mode: config.mode === 'single' ? 'single' : 'paire',
cards: Array.isArray(config.cards) ? config.cards : [],
};
} catch (e) {
return { mode: 'paire', cards: [] };
}
}
function forgeDocShuffleArray(array) {
const copy = [...array];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function forgeDocFillMemoryCardFace(container, face) {
container.innerHTML = '';
if (face.image) {
const img = document.createElement('img');
img.src = face.image;
img.alt = '';
container.appendChild(img);
}
if (face.text) {
const span = document.createElement('span');
span.textContent = face.text;
container.appendChild(span);
}
if (!face.image && !face.text) {
const glyph = document.createElement('span');
glyph.className = 'docMemoryCardBackGlyph';
glyph.textContent = '?';
container.appendChild(glyph);
}
}
function forgeDocBuildMemoryCard(cardData, index) {
const cardEl = document.createElement('div');
cardEl.className = 'docMemoryCard';
cardEl.dataset.instanceIndex = String(index);
cardEl.dataset.cardIndex = String(cardData.card_index);
const inner = document.createElement('div');
inner.className = 'docMemoryCardInner';
const front = document.createElement('div');
front.className = 'docMemoryCardFace docMemoryCardFront';
forgeDocFillMemoryCardFace(front, cardData.recto);
const back = document.createElement('div');
back.className = 'docMemoryCardFace docMemoryCardBack';
forgeDocFillMemoryCardFace(back, cardData.verso);
inner.appendChild(front);
inner.appendChild(back);
cardEl.appendChild(inner);
return cardEl;
}
function forgeDocMemoryUpdateProgress(playerEl, state) {
const progEl = playerEl.querySelector('.docMemoryProg');
progEl.textContent = state.mode === 'single'
? `${state.revealed} / ${state.total} cartes retournées`
: `${state.matched} / ${state.totalPairs} paires trouvées`;
}
function forgeDocMemoryCheckComplete(playerEl, state) {
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
if (!done) return;
playerEl.querySelector('.docMemoryCardWrap').style.display = 'none';
const resultCard = playerEl.querySelector('.docMemoryResultCard');
resultCard.style.display = 'block';
playerEl.querySelector('.docMemoryResultSub').textContent = state.mode === 'single'
? 'Toutes les cartes ont été retournées !'
: 'Toutes les paires ont été trouvées !';
}
function forgeDocMemoryFlip(playerEl, state, cardEl) {
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
if (state.mode === 'single') {
cardEl.classList.add('is-flipped', 'is-matched');
state.revealed += 1;
forgeDocMemoryUpdateProgress(playerEl, state);
forgeDocMemoryCheckComplete(playerEl, state);
return;
}
if (state.busy) return;
cardEl.classList.add('is-flipped');
state.flippedCards.push(cardEl);
if (state.flippedCards.length < 2) return;
state.busy = true;
const [first, second] = state.flippedCards;
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
if (isMatch) {
first.classList.add('is-matched');
second.classList.add('is-matched');
state.matched += 1;
state.flippedCards = [];
state.busy = false;
forgeDocMemoryUpdateProgress(playerEl, state);
forgeDocMemoryCheckComplete(playerEl, state);
} else {
setTimeout(() => {
first.classList.remove('is-flipped');
second.classList.remove('is-flipped');
state.flippedCards = [];
state.busy = false;
}, 800);
}
}
function forgeDocMemoryRestart(playerEl) {
const data = forgeDocMemoryPlayerData(playerEl);
const shuffled = forgeDocShuffleArray(data.cards);
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
const state = {
mode: data.mode,
total: data.cards.length,
totalPairs,
matched: 0,
revealed: 0,
flippedCards: [],
busy: false,
};
playerEl.querySelector('.docMemoryResultCard').style.display = 'none';
playerEl.querySelector('.docMemoryCardWrap').style.display = 'block';
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
? 'Retournez chaque carte'
: 'Retrouvez les paires';
forgeDocMemoryUpdateProgress(playerEl, state);
const grid = playerEl.querySelector('.docMemoryGrid');
grid.innerHTML = '';
shuffled.forEach((cardData, index) => {
const cardEl = forgeDocBuildMemoryCard(cardData, index);
cardEl.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMemoryFlip(playerEl, state, cardEl);
});
grid.appendChild(cardEl);
});
}
function forgeDocInitMemoryPlayers() {
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
const data = forgeDocMemoryPlayerData(playerEl);
if (!data.cards.length) return;
forgeDocMemoryRestart(playerEl);
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMemoryRestart(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);
@@ -1084,15 +1253,20 @@ function forgeDocAssociationNewPair() {
return { left: 'Nouvel élément', right: 'Sa correspondance' }; return { left: 'Nouvel élément', right: 'Sa correspondance' };
} }
function forgeDocEscapeForTextarea(text) { function forgeDocEscapeHtml(text) {
// Les deux côtés d'une paire sont du texte libre du créateur, inséré ici // Texte libre du créateur (Association/Memory), inséré comme CONTENU
// comme CONTENU d'un <textarea> dans un template string (jamais via // d'un <textarea> ou comme VALEUR d'attribut dans un template string
// .value, voir forgeDocRenderAssociationProps qui construit tout le // (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
// panneau en une seule chaîne) — sans cet échappement, un texte // forgeDocRenderMemoryProps qui construisent tout le panneau en une
// contenant littéralement "</textarea>" romprait le tag et tout ce qui // seule chaîne) — sans cet échappement, un texte contenant littéralement
// suit serait interprété comme du HTML/JS brut dans SA PROPRE session // "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
// d'édition. // qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
return String(text).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;'); // session d'édition.
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
} }
function forgeDocRenderAssociationPairHtml(pair, pIndex) { function forgeDocRenderAssociationPairHtml(pair, pIndex) {
@@ -1106,13 +1280,13 @@ function forgeDocRenderAssociationPairHtml(pair, pIndex) {
rows="2" rows="2"
placeholder="Élément" placeholder="Élément"
data-pair-index="${pIndex}" data-pair-index="${pIndex}"
data-side="left">${forgeDocEscapeForTextarea(pair.left)}</textarea> data-side="left">${forgeDocEscapeHtml(pair.left)}</textarea>
<div class="docAssocPairLink" aria-hidden="true">↕</div> <div class="docAssocPairLink" aria-hidden="true">↕</div>
<textarea class="docAssocPairInput" <textarea class="docAssocPairInput"
rows="2" rows="2"
placeholder="Correspondance" placeholder="Correspondance"
data-pair-index="${pIndex}" data-pair-index="${pIndex}"
data-side="right">${forgeDocEscapeForTextarea(pair.right)}</textarea> data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
</div> </div>
`; `;
} }
@@ -1165,6 +1339,116 @@ function forgeDocRenderAssociationProps(panel, el) {
}); });
} }
function forgeDocMemoryNewCard() {
// Verso pré-rempli (jamais vide) : sanitize_memory_config
// (routes/document/document_element_update.py) rejette silencieusement
// toute carte dont le verso est entièrement vide (rien à révéler) —
// même leçon que forgeDocAssociationNewPair/forgeDocQuizNewQuestion
// ci-dessus (voir leur commentaire : sans ça, "+ Ajouter une carte"
// semblerait ne rien faire).
return { recto: { image: '', text: '' }, verso: { image: '', text: 'Nouvelle carte' } };
}
function forgeDocRenderMemoryCardHtml(card, cIndex) {
return `
<div class="docAssocPairRow" data-card-index="${cIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Carte ${cIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-card-index="${cIndex}" title="Supprimer cette carte">🗑</button>
</div>
<span class="docMemoryFaceLabel">Recto (face cachée, optionnel)</span>
<input type="text"
class="docAssocPairInput"
placeholder="URL de l'image"
value="${forgeDocEscapeHtml(card.recto.image)}"
data-card-index="${cIndex}"
data-face="recto"
data-field="image">
<textarea class="docAssocPairInput"
rows="2"
placeholder="Texte"
data-card-index="${cIndex}"
data-face="recto"
data-field="text">${forgeDocEscapeHtml(card.recto.text)}</textarea>
<span class="docMemoryFaceLabel">Verso (révélé au retournement)</span>
<input type="text"
class="docAssocPairInput"
placeholder="URL de l'image"
value="${forgeDocEscapeHtml(card.verso.image)}"
data-card-index="${cIndex}"
data-face="verso"
data-field="image">
<textarea class="docAssocPairInput"
rows="2"
placeholder="Texte"
data-card-index="${cIndex}"
data-face="verso"
data-field="text">${forgeDocEscapeHtml(card.verso.text)}</textarea>
</div>
`;
}
function forgeDocRenderMemoryProps(panel, el) {
const a = el.attributes;
const cards = a.cards || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('memory')}
<div class="docField">
<span class="docFieldLabel">Mode</span>
<div class="docSegmented" id="docMemoryMode">
<button type="button" class="docSegBtn ${a.mode === 'paire' ? 'is-active' : ''}" data-mode="paire">Paire (dupliquer)</button>
<button type="button" class="docSegBtn ${a.mode === 'single' ? 'is-active' : ''}" data-mode="single">Simple (sans doublon)</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMemoryColor">
${['#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">Cartes</div>
<div id="docMemoryCardsList">${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '<div class="docEmptyState">Aucune carte — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMemoryAddCard">+ Ajouter une carte</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMemoryMode .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ mode: btn.dataset.mode }));
});
panel.querySelectorAll('#docMemoryColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docMemoryAddCard').addEventListener('click', () => {
patch({ cards: [...cards, forgeDocMemoryNewCard()] });
});
panel.querySelectorAll('#docMemoryCardsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.cardIndex);
patch({ cards: cards.filter((_, i) => i !== cIndex) });
});
});
panel.querySelectorAll('#docMemoryCardsList .docAssocPairInput').forEach((input) => {
input.addEventListener('change', (e) => {
const cIndex = Number(input.dataset.cardIndex);
const { face, field } = input.dataset;
patch({
cards: cards.map((c, i) => (i === cIndex ? { ...c, [face]: { ...c[face], [field]: e.target.value } } : c)),
});
});
});
}
function forgeDocRenderProps(el) { function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel'); const panel = document.getElementById('docPropsPanel');
if (!el) { if (!el) {
@@ -1178,6 +1462,7 @@ function forgeDocRenderProps(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 if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
else forgeDocRenderMinigameProps(panel, el); else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn'); const deleteBtn = document.getElementById('docDeletePropBtn');
+24
View File
@@ -113,6 +113,30 @@ def test_document_element_update_sanitizes_association_config_on_write(client: F
assert "1 paire" in payload["rendered_html"] assert "1 paire" in payload["rendered_html"]
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory"})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={
"theme_color": "#ffb020",
"mode": "single",
"cards": [
{"recto": {"text": "?"}, "verso": {"text": "Chat"}},
{"recto": {"text": "?"}, "verso": {"image": "", "text": ""}},
],
},
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["mode"] == "single"
assert len(payload["attributes"]["cards"]) == 1
assert "1 carte" in payload["rendered_html"]
assert "mode simple" 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:
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"] first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"]
second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"] second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"]
+95
View File
@@ -0,0 +1,95 @@
"""Modèle de données du mini-jeu Memory
(document_engine/labels/memory_config.py) — sans Flask, teste directement
sanitize_memory_config, et le rendu du plateau."""
from typing import Any
import document_engine as doc_engine
def test_sanitize_memory_config_on_missing_input_returns_full_defaults() -> None:
assert doc_engine.sanitize_memory_config(None) == {"theme_color": "#ff5f2e", "mode": "paire", "cards": []}
def test_sanitize_memory_config_keeps_a_valid_card() -> None:
raw = {
"theme_color": "#123456",
"mode": "single",
"cards": [{"recto": {"image": "", "text": "?"}, "verso": {"image": "cat.png", "text": "Chat"}}],
}
config = doc_engine.sanitize_memory_config(raw)
assert config["theme_color"] == "#123456"
assert config["mode"] == "single"
assert config["cards"] == [{"recto": {"image": "", "text": "?"}, "verso": {"image": "cat.png", "text": "Chat"}}]
def test_sanitize_memory_config_allows_a_fully_blank_recto() -> None:
raw = {"cards": [{"recto": {}, "verso": {"text": "Chat"}}]}
config = doc_engine.sanitize_memory_config(raw)
assert config["cards"] == [{"recto": {"image": "", "text": ""}, "verso": {"image": "", "text": "Chat"}}]
def test_sanitize_memory_config_drops_a_card_with_a_fully_blank_verso() -> None:
raw = {"cards": [{"recto": {"text": "Indice"}, "verso": {"image": "", "text": " "}}]}
assert doc_engine.sanitize_memory_config(raw)["cards"] == []
def test_sanitize_memory_config_falls_back_to_paire_on_invalid_mode() -> None:
assert doc_engine.sanitize_memory_config({"mode": "n_importe_quoi"})["mode"] == "paire"
def test_sanitize_memory_config_caps_cards_at_max() -> None:
raw = {"cards": [{"verso": {"text": f"Carte {i}"}} for i in range(doc_engine.MAX_CARDS + 3)]}
config = doc_engine.sanitize_memory_config(raw)
assert len(config["cards"]) == doc_engine.MAX_CARDS
def test_sanitize_memory_config_ignores_garbage_top_level_input() -> None:
assert doc_engine.sanitize_memory_config("n'importe quoi") == doc_engine.DEFAULT_MEMORY_CONFIG
def _memory_element(attributes: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "kind": "memory", "parent_id": None, "order_index": 0, "attributes": attributes}
def test_render_memory_without_cards_shows_only_the_badge() -> None:
html = doc_engine.render_document_element(_memory_element(doc_engine.DEFAULT_MEMORY_CONFIG), {})
assert "0 carte" in html
assert "docMemoryPlayer" not in html
def test_render_memory_with_cards_includes_the_interactive_board() -> None:
config = doc_engine.sanitize_memory_config(
{"mode": "paire", "cards": [{"verso": {"text": "Chat"}}, {"verso": {"text": "Chien"}}]}
)
html = doc_engine.render_document_element(_memory_element(config), {})
assert "docMemoryPlayer" in html
assert "data-memory-config=" in html
assert "2 cartes" in html
assert "mode paire" in html
def test_render_memory_duplicates_cards_in_paire_mode_only() -> None:
import json
config = doc_engine.sanitize_memory_config({"mode": "paire", "cards": [{"verso": {"text": "Chat"}}]})
html_paire = doc_engine.render_document_element(_memory_element(config), {})
start = html_paire.find('data-memory-config="') + len('data-memory-config="')
end = html_paire.find('"', start)
embedded = json.loads(html_paire[start:end].replace("&quot;", '"'))
assert len(embedded["cards"]) == 2
assert embedded["cards"][0]["card_index"] == embedded["cards"][1]["card_index"] == 0
config_single = doc_engine.sanitize_memory_config({"mode": "single", "cards": [{"verso": {"text": "Chat"}}]})
html_single = doc_engine.render_document_element(_memory_element(config_single), {})
start2 = html_single.find('data-memory-config="') + len('data-memory-config="')
end2 = html_single.find('"', start2)
embedded_single = json.loads(html_single[start2:end2].replace("&quot;", '"'))
assert len(embedded_single["cards"]) == 1
def test_render_memory_escapes_card_text_in_embedded_json() -> None:
config = doc_engine.sanitize_memory_config({"cards": [{"verso": {"text": '"><script>alert(1)</script>'}}]})
html = doc_engine.render_document_element(_memory_element(config), {})
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html