Compare commits
2
Commits
cf46da3388
...
a34a95a3b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a34a95a3b4 | ||
|
|
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 |
|
||||
|---|---|---|---|
|
||||
| `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 |
|
||||
|
||||
@@ -41,6 +41,13 @@ from .labels.element_kind_labels import (
|
||||
SHAPE_KINDS,
|
||||
element_default_attributes,
|
||||
)
|
||||
from .labels.memory_config import (
|
||||
CARD_MODES,
|
||||
DEFAULT_MEMORY_CONFIG,
|
||||
MAX_CARDS,
|
||||
MIN_CARDS,
|
||||
sanitize_memory_config,
|
||||
)
|
||||
from .labels.quiz_config import (
|
||||
DEFAULT_QUIZ_CONFIG,
|
||||
MAX_CHOICES,
|
||||
@@ -53,14 +60,18 @@ from .labels.quiz_config import (
|
||||
from .rendering.render_document_element import render_document, render_document_element
|
||||
|
||||
__all__ = [
|
||||
"CARD_MODES",
|
||||
"CONTENT_KINDS",
|
||||
"DEFAULT_ASSOCIATION_CONFIG",
|
||||
"DEFAULT_MEMORY_CONFIG",
|
||||
"DEFAULT_QUIZ_CONFIG",
|
||||
"ELEMENT_KIND_LABELS",
|
||||
"ELEMENT_LIBRARY",
|
||||
"MAX_CARDS",
|
||||
"MAX_CHOICES",
|
||||
"MAX_PAIRS",
|
||||
"MAX_TIMER_SECONDS",
|
||||
"MIN_CARDS",
|
||||
"MIN_CHOICES",
|
||||
"MIN_PAIRS",
|
||||
"MIN_TIMER_SECONDS",
|
||||
@@ -76,6 +87,7 @@ __all__ = [
|
||||
"render_document",
|
||||
"render_document_element",
|
||||
"sanitize_association_config",
|
||||
"sanitize_memory_config",
|
||||
"sanitize_quiz_config",
|
||||
"update_document_element_attributes",
|
||||
]
|
||||
|
||||
@@ -6,6 +6,7 @@ défaut posés à la création de chaque type."""
|
||||
from typing import Any
|
||||
|
||||
from .association_config import DEFAULT_ASSOCIATION_CONFIG
|
||||
from .memory_config import DEFAULT_MEMORY_CONFIG
|
||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||
|
||||
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
||||
@@ -86,11 +87,14 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
||||
if kind == "association":
|
||||
# Même raison de copie que "quiz" ci-dessus (voir association_config.py).
|
||||
return {**DEFAULT_ASSOCIATION_CONFIG, "pairs": list(DEFAULT_ASSOCIATION_CONFIG["pairs"])}
|
||||
if kind == "memory":
|
||||
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
|
||||
return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
|
||||
if kind in MINIGAME_KINDS:
|
||||
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
||||
# paragraphe : "état par défaut en attendant sa spécification") —
|
||||
# décision actée : cœur complet + mini-jeux en emplacement
|
||||
# réservé (memory/mots/scenario/zones), formulaires de contenu
|
||||
# dédiés = chantier séparé.
|
||||
# réservé (mots/scenario/zones), formulaires de contenu dédiés =
|
||||
# chantier séparé.
|
||||
return {"theme_color": "#ff5f2e"}
|
||||
return {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Modèle de données du mini-jeu Memory (voir docs/plan/PLAN.md §3.2) —
|
||||
l'apprenant retourne des cartes pour constituer des paires identiques
|
||||
(mode "paire") ou simplement révéler chaque carte une fois (mode
|
||||
"single", un retournement classique sans appariement). Même convention
|
||||
resolve_X/sanitize_X que quiz_config.py/association_config.py (aucun
|
||||
import croisé)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
MIN_CARDS = 2
|
||||
MAX_CARDS = 8
|
||||
CARD_MODES = ("paire", "single")
|
||||
DEFAULT_MODE = "paire"
|
||||
|
||||
DEFAULT_MEMORY_CONFIG: dict[str, Any] = {
|
||||
"theme_color": "#ff5f2e",
|
||||
"mode": DEFAULT_MODE,
|
||||
"cards": [],
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_card_face(raw: Any) -> dict[str, str]:
|
||||
"""Une face de carte (recto ou verso) — image et texte tous deux
|
||||
optionnels et indépendants (le créateur peut mettre l'un, l'autre, ou
|
||||
les deux, voir docs/plan/PLAN.md)."""
|
||||
if not isinstance(raw, dict):
|
||||
return {"image": "", "text": ""}
|
||||
return {
|
||||
"image": str(raw.get("image", "")).strip(),
|
||||
"text": str(raw.get("text", "")).strip(),
|
||||
}
|
||||
|
||||
|
||||
def _sanitize_card(raw: Any) -> dict[str, Any] | None:
|
||||
"""None si la carte est invalide — un verso entièrement vide (ni image
|
||||
ni texte) n'aurait rien à révéler/apparier, contrairement au recto qui
|
||||
peut légitimement rester vide (dos de carte générique par défaut)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
recto = _sanitize_card_face(raw.get("recto"))
|
||||
verso = _sanitize_card_face(raw.get("verso"))
|
||||
if not verso["image"] and not verso["text"]:
|
||||
return None
|
||||
return {"recto": recto, "verso": verso}
|
||||
|
||||
|
||||
def sanitize_memory_config(raw_config: Any) -> dict[str, Any]:
|
||||
config = dict(DEFAULT_MEMORY_CONFIG)
|
||||
if not isinstance(raw_config, dict):
|
||||
return config
|
||||
theme_color = raw_config.get("theme_color")
|
||||
if isinstance(theme_color, str) and theme_color:
|
||||
config["theme_color"] = theme_color
|
||||
mode = raw_config.get("mode")
|
||||
config["mode"] = mode if mode in CARD_MODES else DEFAULT_MODE
|
||||
raw_cards = raw_config.get("cards")
|
||||
if isinstance(raw_cards, list):
|
||||
cards = [c for c in (_sanitize_card(item) for item in raw_cards) if c is not None]
|
||||
config["cards"] = cards[:MAX_CARDS]
|
||||
return config
|
||||
@@ -250,6 +250,64 @@ def _render_association(el: dict[str, Any], _children_by_parent: dict[int | None
|
||||
)
|
||||
|
||||
|
||||
def _render_memory_player(config: dict[str, Any]) -> str:
|
||||
"""Plateau de Memory RÉELLEMENT interactif — affiché uniquement en
|
||||
Mode Aperçu, même principe que _render_quiz_player/
|
||||
_render_association_player. En mode "paire", chaque carte définie par
|
||||
le créateur est dupliquée en deux instances partageant le même
|
||||
card_index (l'appariement se fait dessus) ; en mode "single", une
|
||||
seule instance par carte (simple retournement, sans appariement).
|
||||
Les instances sont mélangées une seule fois ici (jamais recalculées
|
||||
à chaque rendu répété d'un même Aperçu, voir la remarque dans
|
||||
static/document/js/document-editor.js sur la ré-init au
|
||||
rafraîchissement du canevas) puis embarquées en JSON."""
|
||||
cards = config["cards"]
|
||||
mode = config["mode"]
|
||||
instances = []
|
||||
for i, card in enumerate(cards):
|
||||
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
|
||||
if mode == "paire":
|
||||
instances.append({"card_index": i, "recto": card["recto"], "verso": card["verso"]})
|
||||
random.shuffle(instances) # NOSONAR python:S2245 - melange d'affichage, pas un usage cryptographique
|
||||
config_json = html_lib.escape(json.dumps({"mode": mode, "cards": instances}), quote=True)
|
||||
return (
|
||||
f'<div class="docMemoryPlayer" data-memory-config="{config_json}">'
|
||||
f'<div class="docMemoryCardWrap">'
|
||||
f'<div class="docQuizKicker">Memory</div>'
|
||||
f'<div class="docAssocTitle docMemoryTitle"></div>'
|
||||
f'<div class="docAssocMeta"><span class="docMemoryProg"></span></div>'
|
||||
f'<div class="docMemoryGrid"></div>'
|
||||
f"</div>"
|
||||
f'<div class="docAssocResultCard docMemoryResultCard" style="display:none;">'
|
||||
f'<div class="docAssocResultBig">✓</div>'
|
||||
f'<div class="docAssocResultSub docMemoryResultSub"></div>'
|
||||
f'<button type="button" class="docAssocRestartBtn docMemoryRestartBtn">Recommencer</button>'
|
||||
f"</div></div>"
|
||||
)
|
||||
|
||||
|
||||
def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
from ..labels.memory_config import sanitize_memory_config
|
||||
|
||||
config = sanitize_memory_config(el["attributes"])
|
||||
theme_color = html_lib.escape(str(config["theme_color"]))
|
||||
card_count = len(config["cards"])
|
||||
card_label = "carte" if card_count <= 1 else "cartes"
|
||||
mode_label = "mode paire" if config["mode"] == "paire" else "mode simple"
|
||||
subtitle = f"{card_count} {card_label} · {mode_label}"
|
||||
player_html = _render_memory_player(config) if config["cards"] else ""
|
||||
return (
|
||||
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="memory" '
|
||||
f'style="border-color:{theme_color};">'
|
||||
f'<div class="docMinigameBadge">'
|
||||
f'<span class="docMinigameLabel">Memory</span>'
|
||||
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
|
||||
f"</div>"
|
||||
f"{player_html}"
|
||||
f"</div>"
|
||||
)
|
||||
|
||||
|
||||
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
||||
|
||||
@@ -266,7 +324,7 @@ _RENDERERS = {
|
||||
"bouton": _render_button,
|
||||
"quiz": _render_quiz,
|
||||
"association": _render_association,
|
||||
"memory": _render_minigame_placeholder,
|
||||
"memory": _render_memory,
|
||||
"mots": _render_minigame_placeholder,
|
||||
"scenario": _render_minigame_placeholder,
|
||||
"zones": _render_minigame_placeholder,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,113 @@ 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 {
|
||||
/* Une paire trouvée reste face retournée en PERMANENCE (voir
|
||||
forgeDocMemoryFlip, static/document/js/document-editor.js — la
|
||||
classe is-flipped n'est jamais retirée pour une carte appariée).
|
||||
Pas d'opacité réduite ici : une carte à 55% d'opacité, à côté de
|
||||
cartes face cachée pleinement opaques, se lisait comme "repartie
|
||||
face cachée" (retour utilisateur du 20/09/2026) alors qu'elle
|
||||
restait techniquement retournée — la bordure verte ci-dessous
|
||||
suffit à signaler l'état "trouvée", sans jamais assombrir le
|
||||
contenu révélé. */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.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.docMemoryCardBack {
|
||||
border-color: var(--doc-quiz-success-border);
|
||||
background: var(--doc-quiz-success-bg);
|
||||
color: var(--doc-quiz-success-text);
|
||||
}
|
||||
|
||||
.docUnknown {
|
||||
font-size: 12.5px;
|
||||
color: var(--doc-muted);
|
||||
|
||||
@@ -477,6 +477,7 @@ function forgeDocBindCanvasInteractions() {
|
||||
});
|
||||
forgeDocInitQuizPlayers();
|
||||
forgeDocInitAssociationPlayers();
|
||||
forgeDocInitMemoryPlayers();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
@@ -746,6 +747,174 @@ function forgeDocInitAssociationPlayers() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
|
||||
* mode "paire" (retrouver les deux cartes identiques, classique) ou
|
||||
* "single" (retourner chaque carte une fois, sans appariement). Contenu
|
||||
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
|
||||
* avec le texte du créateur) — même précaution que le plateau Association.
|
||||
* ------------------------------------------------------------------- */
|
||||
|
||||
function forgeDocMemoryPlayerData(playerEl) {
|
||||
try {
|
||||
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
|
||||
return {
|
||||
mode: config.mode === 'single' ? 'single' : 'paire',
|
||||
cards: Array.isArray(config.cards) ? config.cards : [],
|
||||
};
|
||||
} catch (e) {
|
||||
return { mode: 'paire', cards: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocShuffleArray(array) {
|
||||
const copy = [...array];
|
||||
for (let i = copy.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[copy[i], copy[j]] = [copy[j], copy[i]];
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function forgeDocFillMemoryCardFace(container, face) {
|
||||
container.innerHTML = '';
|
||||
if (face.image) {
|
||||
const img = document.createElement('img');
|
||||
img.src = face.image;
|
||||
img.alt = '';
|
||||
container.appendChild(img);
|
||||
}
|
||||
if (face.text) {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = face.text;
|
||||
container.appendChild(span);
|
||||
}
|
||||
if (!face.image && !face.text) {
|
||||
const glyph = document.createElement('span');
|
||||
glyph.className = 'docMemoryCardBackGlyph';
|
||||
glyph.textContent = '?';
|
||||
container.appendChild(glyph);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocBuildMemoryCard(cardData, index) {
|
||||
const cardEl = document.createElement('div');
|
||||
cardEl.className = 'docMemoryCard';
|
||||
cardEl.dataset.instanceIndex = String(index);
|
||||
cardEl.dataset.cardIndex = String(cardData.card_index);
|
||||
|
||||
const inner = document.createElement('div');
|
||||
inner.className = 'docMemoryCardInner';
|
||||
|
||||
const front = document.createElement('div');
|
||||
front.className = 'docMemoryCardFace docMemoryCardFront';
|
||||
forgeDocFillMemoryCardFace(front, cardData.recto);
|
||||
|
||||
const back = document.createElement('div');
|
||||
back.className = 'docMemoryCardFace docMemoryCardBack';
|
||||
forgeDocFillMemoryCardFace(back, cardData.verso);
|
||||
|
||||
inner.appendChild(front);
|
||||
inner.appendChild(back);
|
||||
cardEl.appendChild(inner);
|
||||
return cardEl;
|
||||
}
|
||||
|
||||
function forgeDocMemoryUpdateProgress(playerEl, state) {
|
||||
const progEl = playerEl.querySelector('.docMemoryProg');
|
||||
progEl.textContent = state.mode === 'single'
|
||||
? `${state.revealed} / ${state.total} cartes retournées`
|
||||
: `${state.matched} / ${state.totalPairs} paires trouvées`;
|
||||
}
|
||||
|
||||
function forgeDocMemoryCheckComplete(playerEl, state) {
|
||||
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
|
||||
if (!done) return;
|
||||
playerEl.querySelector('.docMemoryCardWrap').style.display = 'none';
|
||||
const resultCard = playerEl.querySelector('.docMemoryResultCard');
|
||||
resultCard.style.display = 'block';
|
||||
playerEl.querySelector('.docMemoryResultSub').textContent = state.mode === 'single'
|
||||
? 'Toutes les cartes ont été retournées !'
|
||||
: 'Toutes les paires ont été trouvées !';
|
||||
}
|
||||
|
||||
function forgeDocMemoryFlip(playerEl, state, cardEl) {
|
||||
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
|
||||
if (state.mode === 'single') {
|
||||
cardEl.classList.add('is-flipped', 'is-matched');
|
||||
state.revealed += 1;
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
forgeDocMemoryCheckComplete(playerEl, state);
|
||||
return;
|
||||
}
|
||||
if (state.busy) return;
|
||||
cardEl.classList.add('is-flipped');
|
||||
state.flippedCards.push(cardEl);
|
||||
if (state.flippedCards.length < 2) return;
|
||||
state.busy = true;
|
||||
const [first, second] = state.flippedCards;
|
||||
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
|
||||
if (isMatch) {
|
||||
first.classList.add('is-matched');
|
||||
second.classList.add('is-matched');
|
||||
state.matched += 1;
|
||||
state.flippedCards = [];
|
||||
state.busy = false;
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
forgeDocMemoryCheckComplete(playerEl, state);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
first.classList.remove('is-flipped');
|
||||
second.classList.remove('is-flipped');
|
||||
state.flippedCards = [];
|
||||
state.busy = false;
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocMemoryRestart(playerEl) {
|
||||
const data = forgeDocMemoryPlayerData(playerEl);
|
||||
const shuffled = forgeDocShuffleArray(data.cards);
|
||||
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
|
||||
const state = {
|
||||
mode: data.mode,
|
||||
total: data.cards.length,
|
||||
totalPairs,
|
||||
matched: 0,
|
||||
revealed: 0,
|
||||
flippedCards: [],
|
||||
busy: false,
|
||||
};
|
||||
playerEl.querySelector('.docMemoryResultCard').style.display = 'none';
|
||||
playerEl.querySelector('.docMemoryCardWrap').style.display = 'block';
|
||||
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
|
||||
? 'Retournez chaque carte'
|
||||
: 'Retrouvez les paires';
|
||||
forgeDocMemoryUpdateProgress(playerEl, state);
|
||||
const grid = playerEl.querySelector('.docMemoryGrid');
|
||||
grid.innerHTML = '';
|
||||
shuffled.forEach((cardData, index) => {
|
||||
const cardEl = forgeDocBuildMemoryCard(cardData, index);
|
||||
cardEl.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocMemoryFlip(playerEl, state, cardEl);
|
||||
});
|
||||
grid.appendChild(cardEl);
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocInitMemoryPlayers() {
|
||||
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
|
||||
const data = forgeDocMemoryPlayerData(playerEl);
|
||||
if (!data.cards.length) return;
|
||||
forgeDocMemoryRestart(playerEl);
|
||||
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocMemoryRestart(playerEl);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocSetActiveBottomNav(target) {
|
||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||
b.classList.toggle('is-active', b.dataset.target === target);
|
||||
@@ -1084,15 +1253,20 @@ function forgeDocAssociationNewPair() {
|
||||
return { left: 'Nouvel élément', right: 'Sa correspondance' };
|
||||
}
|
||||
|
||||
function forgeDocEscapeForTextarea(text) {
|
||||
// Les deux côtés d'une paire sont du texte libre du créateur, inséré ici
|
||||
// comme CONTENU d'un <textarea> dans un template string (jamais via
|
||||
// .value, voir forgeDocRenderAssociationProps qui construit tout le
|
||||
// panneau en une seule chaîne) — sans cet échappement, un texte
|
||||
// contenant littéralement "</textarea>" romprait le tag et tout ce qui
|
||||
// suit serait interprété comme du HTML/JS brut dans SA PROPRE session
|
||||
// d'édition.
|
||||
return String(text).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
function forgeDocEscapeHtml(text) {
|
||||
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
|
||||
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
|
||||
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
|
||||
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
|
||||
// seule chaîne) — sans cet échappement, un texte contenant littéralement
|
||||
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
|
||||
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
|
||||
// session d'édition.
|
||||
return String(text)
|
||||
.replaceAll('&', '&')
|
||||
.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)}</textarea>
|
||||
data-side="left">${forgeDocEscapeHtml(pair.left)}</textarea>
|
||||
<div class="docAssocPairLink" aria-hidden="true">↕</div>
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Correspondance"
|
||||
data-pair-index="${pIndex}"
|
||||
data-side="right">${forgeDocEscapeForTextarea(pair.right)}</textarea>
|
||||
data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1165,6 +1339,116 @@ function forgeDocRenderAssociationProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocMemoryNewCard() {
|
||||
// Verso pré-rempli (jamais vide) : sanitize_memory_config
|
||||
// (routes/document/document_element_update.py) rejette silencieusement
|
||||
// toute carte dont le verso est entièrement vide (rien à révéler) —
|
||||
// même leçon que forgeDocAssociationNewPair/forgeDocQuizNewQuestion
|
||||
// ci-dessus (voir leur commentaire : sans ça, "+ Ajouter une carte"
|
||||
// semblerait ne rien faire).
|
||||
return { recto: { image: '', text: '' }, verso: { image: '', text: 'Nouvelle carte' } };
|
||||
}
|
||||
|
||||
function forgeDocRenderMemoryCardHtml(card, cIndex) {
|
||||
return `
|
||||
<div class="docAssocPairRow" data-card-index="${cIndex}">
|
||||
<div class="docQuizQuestionHeader">
|
||||
<span class="docFieldLabel">Carte ${cIndex + 1}</span>
|
||||
<button type="button" class="docQuizRemoveBtn" data-card-index="${cIndex}" title="Supprimer cette carte">🗑</button>
|
||||
</div>
|
||||
<span class="docMemoryFaceLabel">Recto (face cachée, optionnel)</span>
|
||||
<input type="text"
|
||||
class="docAssocPairInput"
|
||||
placeholder="URL de l'image"
|
||||
value="${forgeDocEscapeHtml(card.recto.image)}"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="recto"
|
||||
data-field="image">
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Texte"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="recto"
|
||||
data-field="text">${forgeDocEscapeHtml(card.recto.text)}</textarea>
|
||||
<span class="docMemoryFaceLabel">Verso (révélé au retournement)</span>
|
||||
<input type="text"
|
||||
class="docAssocPairInput"
|
||||
placeholder="URL de l'image"
|
||||
value="${forgeDocEscapeHtml(card.verso.image)}"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="verso"
|
||||
data-field="image">
|
||||
<textarea class="docAssocPairInput"
|
||||
rows="2"
|
||||
placeholder="Texte"
|
||||
data-card-index="${cIndex}"
|
||||
data-face="verso"
|
||||
data-field="text">${forgeDocEscapeHtml(card.verso.text)}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderMemoryProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
const cards = a.cards || [];
|
||||
|
||||
function patch(partial) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('memory')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Mode</span>
|
||||
<div class="docSegmented" id="docMemoryMode">
|
||||
<button type="button" class="docSegBtn ${a.mode === 'paire' ? 'is-active' : ''}" data-mode="paire">Paire (dupliquer)</button>
|
||||
<button type="button" class="docSegBtn ${a.mode === 'single' ? 'is-active' : ''}" data-mode="single">Simple (sans doublon)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Couleur du thème</span>
|
||||
<div class="docSwatches" id="docMemoryColor">
|
||||
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="docSectionLabel docQuizQuestionsLabel">Cartes</div>
|
||||
<div id="docMemoryCardsList">${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '<div class="docEmptyState">Aucune carte — ajoute la première ci-dessous.</div>'}</div>
|
||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMemoryAddCard">+ Ajouter une carte</button>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('#docMemoryMode .docSegBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => patch({ mode: btn.dataset.mode }));
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryColor .docSwatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||
});
|
||||
|
||||
document.getElementById('docMemoryAddCard').addEventListener('click', () => {
|
||||
patch({ cards: [...cards, forgeDocMemoryNewCard()] });
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryCardsList .docQuizRemoveBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const cIndex = Number(btn.dataset.cardIndex);
|
||||
patch({ cards: cards.filter((_, i) => i !== cIndex) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('#docMemoryCardsList .docAssocPairInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const cIndex = Number(input.dataset.cardIndex);
|
||||
const { face, field } = input.dataset;
|
||||
patch({
|
||||
cards: cards.map((c, i) => (i === cIndex ? { ...c, [face]: { ...c[face], [field]: e.target.value } } : c)),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderProps(el) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -1178,6 +1462,7 @@ function forgeDocRenderProps(el) {
|
||||
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
||||
else forgeDocRenderMinigameProps(panel, el);
|
||||
|
||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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