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 |
|---|---|---|---|
| `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 |
| `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 |
+12
View File
@@ -41,6 +41,13 @@ from .labels.element_kind_labels import (
SHAPE_KINDS,
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 (
DEFAULT_QUIZ_CONFIG,
MAX_CHOICES,
@@ -53,14 +60,18 @@ from .labels.quiz_config import (
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
"CARD_MODES",
"CONTENT_KINDS",
"DEFAULT_ASSOCIATION_CONFIG",
"DEFAULT_MEMORY_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MAX_CARDS",
"MAX_CHOICES",
"MAX_PAIRS",
"MAX_TIMER_SECONDS",
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
"MIN_TIMER_SECONDS",
@@ -76,6 +87,7 @@ __all__ = [
"render_document",
"render_document_element",
"sanitize_association_config",
"sanitize_memory_config",
"sanitize_quiz_config",
"update_document_element_attributes",
]
@@ -6,6 +6,7 @@ défaut posés à la création de chaque type."""
from typing import Any
from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
@@ -86,11 +87,14 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
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 == "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:
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
# paragraphe : "état par défaut en attendant sa spécification") —
# décision actée : cœur complet + mini-jeux en emplacement
# réservé (memory/mots/scenario/zones), formulaires de contenu
# dédiés = chantier séparé.
# réservé (mots/scenario/zones), formulaires de contenu dédiés =
# chantier séparé.
return {"theme_color": "#ff5f2e"}
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.
## `MINIGAME_KINDS: tuple[str, ...]`
`("quiz", "association", "memory", "mots", "scenario", "zones")` — `"quiz"`
et `"association"` sont implémentés (voir `quiz_config.py`/
`association_config.py` ci-dessous) ; les 4 autres gardent un panneau
Propriétés minimal (emplacement réservé), formulaires de contenu dédiés
hors périmètre de cette passe.
`("quiz", "association", "memory", "mots", "scenario", "zones")` —
`"quiz"`, `"association"` et `"memory"` sont implémentés (voir
`quiz_config.py`/`association_config.py`/`memory_config.py` ci-dessous) ;
les 3 autres gardent un panneau Propriétés minimal (emplacement réservé),
formulaires de contenu dédiés hors périmètre de cette passe.
## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]`
Bibliothèque affichée dans le panneau gauche, groupée par catégorie
@@ -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`),
image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`),
quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), association
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), autre
mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu.
(`DEFAULT_ASSOCIATION_CONFIG`, voir `association_config.py`), memory
(`DEFAULT_MEMORY_CONFIG`, voir `memory_config.py`), autre mini-jeu
(`theme_color`), ou `{}` pour un `kind` inconnu.
- **Exceptions** : aucune.
## `quiz_config.py` — modèle de données du mini-jeu Quiz
@@ -102,3 +103,39 @@ 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.
## `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:
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,
"quiz": _render_quiz,
"association": _render_association,
"memory": _render_minigame_placeholder,
"memory": _render_memory,
"mots": _render_minigame_placeholder,
"scenario": _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
`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
- **Memory** : toujours une carte résumant la config réelle (nombre de
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é,
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
systématiquement l'état complet de ses champs.
Quiz et Association sont les seuls kinds revalidés côté serveur
(sanitize_quiz_config/sanitize_association_config, même convention que
Quiz, Association et Memory sont les seuls kinds revalidés côté
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
de questions/choix, liste de paires) doit rester structurellement
correcte pour que le rendu ne plante jamais, contrairement aux autres
kinds (texte/forme/image...) dont les attributs sont de simples
valeurs scalaires sans structure à garantir."""
de questions/choix, liste de paires, liste de cartes) doit rester
structurellement correcte pour que le rendu ne plante jamais,
contrairement aux autres kinds (texte/forme/image...) dont les
attributs sont de simples valeurs scalaires sans structure à
garantir."""
element = document_engine.get_document_element(slug, element_id)
if element is None:
return jsonify({"error": "élément introuvable"}), 404
@@ -29,6 +31,8 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
attributes = document_engine.sanitize_quiz_config(attributes)
elif element["kind"] == "association":
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)
element = db.assert_not_none(
document_engine.get_document_element(slug, element_id),
+111 -9
View File
@@ -500,27 +500,31 @@ img.docImage {
color: var(--doc-muted);
}
/* ---- Quiz/Association : mini-jeux interactifs en Mode Aperçu (voir
docs/plan/maquettes/document-formation-web.html — référence visuelle
du Quiz). Masqués en édition (seul .docMinigameBadge y est visible,
voir plus haut) ; jamais les deux visibles en même temps. ---- */
/* ---- Quiz/Association/Memory : mini-jeux interactifs en Mode Aperçu
(voir docs/plan/maquettes/document-formation-web.html — référence
visuelle du Quiz). Masqués en édition (seul .docMinigameBadge y est
visible, voir plus haut) ; jamais les deux visibles en même temps. ---- */
.docQuizPlayer,
.docAssocPlayer {
.docAssocPlayer,
.docMemoryPlayer {
display: none;
}
.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;
}
.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;
}
.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;
background: transparent;
}
@@ -528,7 +532,9 @@ img.docImage {
.docEditor3.docEditor3--preview .docQuizPlayer,
.docEditor3.docEditor3--preview .docQuizPlayer *,
.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]
(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
@@ -881,6 +887,102 @@ img.docImage {
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 {
font-size: 12.5px;
color: var(--doc-muted);
+296 -11
View File
@@ -477,6 +477,7 @@ function forgeDocBindCanvasInteractions() {
});
forgeDocInitQuizPlayers();
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) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
@@ -1084,15 +1253,20 @@ function forgeDocAssociationNewPair() {
return { left: 'Nouvel élément', right: 'Sa correspondance' };
}
function forgeDocEscapeForTextarea(text) {
// Les deux côtés d'une paire sont du texte libre du créateur, inséré ici
// comme CONTENU d'un <textarea> dans un template string (jamais via
// .value, voir forgeDocRenderAssociationProps qui construit tout le
// panneau en une seule chaîne) — sans cet échappement, un texte
// contenant littéralement "</textarea>" romprait le tag et tout ce qui
// suit serait interprété comme du HTML/JS brut dans SA PROPRE session
// d'édition.
return String(text).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
function forgeDocEscapeHtml(text) {
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
// seule chaîne) — sans cet échappement, un texte contenant littéralement
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
// session d'édition.
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
@@ -1106,13 +1280,13 @@ function forgeDocRenderAssociationPairHtml(pair, pIndex) {
rows="2"
placeholder="Élément"
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>
<textarea class="docAssocPairInput"
rows="2"
placeholder="Correspondance"
data-pair-index="${pIndex}"
data-side="right">${forgeDocEscapeForTextarea(pair.right)}</textarea>
data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
</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) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
@@ -1178,6 +1462,7 @@ function forgeDocRenderProps(el) {
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
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"]
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:
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"]
+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