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:
co-authored by
Claude Sonnet 5
parent
cf46da3388
commit
4111081e1a
+1
-1
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
|||||||
| Site(s) | Outil / règle | Raison | Contexte |
|
| Site(s) | Outil / règle | Raison | Contexte |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
||||||
| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `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 |
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
// session d'édition.
|
||||||
|
return String(text)
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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(""", '"'))
|
||||||
|
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(""", '"'))
|
||||||
|
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 "<script>" in html
|
||||||
Reference in New Issue
Block a user