diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index 9d91d7fb..e85b0e9a 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -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 | diff --git a/document_engine/__init__.py b/document_engine/__init__.py index e6da60f9..3a866686 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -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", ] diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index f2a535b4..8a6271c5 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -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 {} diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index 519e6cef..6fd9efd7 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -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. diff --git a/document_engine/labels/memory_config.py b/document_engine/labels/memory_config.py new file mode 100644 index 00000000..1a25485e --- /dev/null +++ b/document_engine/labels/memory_config.py @@ -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 diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index 26f01553..d26e7637 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -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'
' + f'
' + f'
Memory
' + f'
' + f'
' + f'
' + f"
" + f'
" + ) + + +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'
' + f'
' + f'Memory' + f'{html_lib.escape(subtitle)}' + f"
" + f"{player_html}" + f"
" + ) + + def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: return f'
Type inconnu : {html_lib.escape(el["kind"])}
' @@ -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, diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index 14a23ed1..68b82f32 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -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. diff --git a/routes/document/document_element_update.py b/routes/document/document_element_update.py index e8135768..203b0e8f 100644 --- a/routes/document/document_element_update.py +++ b/routes/document/document_element_update.py @@ -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), diff --git a/static/document/document-editor.css b/static/document/document-editor.css index be153cbd..599c04fd 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -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); diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index f9d75c49..b99f2af8 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -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 " 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('&', '&').replaceAll('<', '<').replaceAll('>', '>'); +function forgeDocEscapeHtml(text) { + // Texte libre du créateur (Association/Memory), inséré comme CONTENU + // d'un " 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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); } 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)} + data-side="left">${forgeDocEscapeHtml(pair.left)} + data-side="right">${forgeDocEscapeHtml(pair.right)} `; } @@ -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 ` +
+
+ Carte ${cIndex + 1} + +
+ Recto (face cachée, optionnel) + + + Verso (révélé au retournement) + + +
+ `; +} + +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')} +
+ Mode +
+ + +
+
+
+ Couleur du thème +
+ ${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `
`).join('')} +
+
+
Cartes
+
${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '
Aucune carte — ajoute la première ci-dessous.
'}
+ + ${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'); diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index fd13ad69..521d210d 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -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"] diff --git a/tests/document/test_memory_config.py b/tests/document/test_memory_config.py new file mode 100644 index 00000000..53dd78cd --- /dev/null +++ b/tests/document/test_memory_config.py @@ -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": '">'}}]}) + html = doc_engine.render_document_element(_memory_element(config), {}) + assert "" not in html + assert "<script>" in html