Implemente le mini-jeu Mots meles (grille reelle, placement 4 directions)
Build and deploy / test-python (push) Failing after 1m0s
Build and deploy / test-js (push) Failing after 50s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s

Quatrieme mini-jeu du support de formation : le createur ecrit 5 a 10
mots (recommandation souple, comme MIN_PAIRS/MIN_CARDS), places par le
serveur dans une grille carree horizontalement, verticalement, ou en
diagonale (deux sens seulement, jamais a l'envers) via un vrai
algorithme de placement avec retry/agrandissement de grille en cas de
conflit. La grille ET la position exacte de chaque mot sont calculees
cote serveur puis embarquees en JSON ; le client valide chaque
selection (glisser ou cliquer-cliquer) par comparaison de coordonnees
exactes, jamais une simple comparaison de texte (qui se tromperait sur
des lettres partagees entre deux mots qui se croisent). Comme
Association/Memory, la grille reste affichee une fois tous les mots
trouves : seul le bouton Recommencer (.docMinigameRestartBar, partage)
apparait.

Verifie via simulation DOM reelle (jsdom) : selection au glisser ET au
clic-clic, mot invalide sans crash, barre de fin qui bascule, panneau
Proprietes (ajout/suppression de mot avec revalidation serveur).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 15:31:46 +02:00
co-authored by Claude Sonnet 5
parent 1b1307bb85
commit d8be80ebd1
11 changed files with 789 additions and 20 deletions
+1 -1
View File
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
| Site(s) | Outil / règle | Raison | Contexte |
|---|---|---|---|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×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 |
| `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` (×7 : `_render_association_player` ×2, `_render_memory_player` ×1, `_mots_place_word` ×3, `_mots_attempt_placement` ×1 — les 3 derniers ajoutés le 20/09/2026 pour le mini-jeu Mots mêlés) | `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, direction/position de placement + lettre de remplissage de la grille du mini-jeu Mots mêlés) — 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 |
+10
View File
@@ -48,6 +48,12 @@ from .labels.memory_config import (
MIN_CARDS,
sanitize_memory_config,
)
from .labels.mots_config import (
DEFAULT_MOTS_CONFIG,
MAX_WORDS,
MIN_WORDS,
sanitize_mots_config,
)
from .labels.quiz_config import (
DEFAULT_QUIZ_CONFIG,
MAX_CHOICES,
@@ -64,6 +70,7 @@ __all__ = [
"CONTENT_KINDS",
"DEFAULT_ASSOCIATION_CONFIG",
"DEFAULT_MEMORY_CONFIG",
"DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
@@ -71,10 +78,12 @@ __all__ = [
"MAX_CHOICES",
"MAX_PAIRS",
"MAX_TIMER_SECONDS",
"MAX_WORDS",
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
"MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS",
"SHAPE_KINDS",
"add_document_element",
@@ -88,6 +97,7 @@ __all__ = [
"render_document_element",
"sanitize_association_config",
"sanitize_memory_config",
"sanitize_mots_config",
"sanitize_quiz_config",
"update_document_element_attributes",
]
@@ -7,6 +7,7 @@ from typing import Any
from .association_config import DEFAULT_ASSOCIATION_CONFIG
from .memory_config import DEFAULT_MEMORY_CONFIG
from .mots_config import DEFAULT_MOTS_CONFIG
from .quiz_config import DEFAULT_QUIZ_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
@@ -90,6 +91,9 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
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 == "mots":
# Même raison de copie que "quiz"/"association"/"memory" ci-dessus (voir mots_config.py).
return {**DEFAULT_MOTS_CONFIG, "words": list(DEFAULT_MOTS_CONFIG["words"])}
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") —
+40
View File
@@ -139,3 +139,43 @@ 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.
## `mots_config.py` — modèle de données du mini-jeu Mots mêlés
Quatrième mini-jeu implémenté : l'apprenant retrouve chaque mot caché
dans une grille de lettres (horizontalement, verticalement, ou en
diagonale — voir `document_engine/rendering/render_document_element.py`::
`_render_mots_player`, qui construit la grille elle-même). Même
convention que `quiz_config.py`.
### `MIN_WORDS`, `MAX_WORDS: int`
Bornes de validation (`5`/`10` mots). `MIN_WORDS` n'est pas imposé par
`sanitize_mots_config` (même logique que `MIN_PAIRS`/`MIN_CARDS`
côté Association/Memory) — recommandation pour le panneau Propriétés,
pas une contrainte technique du rendu : un seul mot valide reste
accepté, la grille se construit quand même autour de lui.
### `MIN_WORD_LENGTH`, `MAX_WORD_LENGTH: int`
Bornes de longueur d'un mot individuel une fois nettoyé (`2`/`20`
lettres) — un mot trop court n'a pas de sens à chercher, un mot trop
long compliquerait inutilement le calcul de la taille de la grille
(voir `_mots_grid_size_for_words` dans `render_document_element.py`).
### `DEFAULT_MOTS_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "words": []}`.
### `sanitize_mots_config(raw_config: Any) -> dict[str, Any]`
Valide/nettoie une config de mots mêlés arbitraire (JSON venu du
client) — jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
`DEFAULT_MOTS_CONFIG`. Chaque mot de `raw_config["words"]` est nettoyé
indépendamment (voir `_sanitize_word`, privée) : mis en MAJUSCULES,
les accents sont retirés (décomposition NFKD + filtrage ASCII, pour que
deux mots qui se croisent sur une même case de la grille puissent
partager exactement la même lettre), seules les lettres sont
conservées. Un mot qui ne contient plus rien d'exploitable (ou moins de
`MIN_WORD_LENGTH` lettres) une fois nettoyé est silencieusement
supprimé de la liste, de même qu'un doublon exact d'un mot déjà retenu
(un mot répété deux fois n'aurait rien de plus à faire trouver). La
liste finale est tronquée à `MAX_WORDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MOTS_CONFIG`).
- **Exceptions** : aucune.
+59
View File
@@ -0,0 +1,59 @@
"""Modèle de données du mini-jeu Mots mêlés (voir docs/plan/PLAN.md §3.2)
— l'apprenant retrouve chaque mot caché dans une grille de lettres, placé
horizontalement, verticalement, ou en diagonale (haut-gauche vers
bas-droite, ou haut-droite vers bas-gauche), jamais à l'envers ni en
diagonale inversée (voir document_engine/rendering/render_document_element.py
::_render_mots_player, qui construit la grille elle-même). Même
convention resolve_X/sanitize_X que quiz_config.py/association_config.py/
memory_config.py (aucun import croisé)."""
import unicodedata
from typing import Any
MIN_WORDS = 5
MAX_WORDS = 10
MIN_WORD_LENGTH = 2
MAX_WORD_LENGTH = 20
DEFAULT_MOTS_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"words": [],
}
def _sanitize_word(raw: Any) -> str | None:
"""None si, une fois nettoyé, il ne reste aucune lettre exploitable —
filtré par sanitize_mots_config plutôt que de faire échouer toute la
grille, même convention que quiz_config.py::_sanitize_question. Les
accents sont retirés (décomposition NFKD puis filtrage ASCII) : deux
mots qui se croisent sur une même case doivent pouvoir partager
exactement la même lettre, ce qu'un "É" et un "E" ne permettraient
pas."""
if not isinstance(raw, str):
return None
decomposed = unicodedata.normalize("NFKD", raw.upper())
letters = "".join(ch for ch in decomposed if ch.isalpha() and ch.isascii())
if len(letters) < MIN_WORD_LENGTH:
return None
return letters[:MAX_WORD_LENGTH]
def sanitize_mots_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_MOTS_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
raw_words = raw_config.get("words")
if isinstance(raw_words, list):
words: list[str] = []
seen: set[str] = set()
for item in raw_words:
word = _sanitize_word(item)
if word is None or word in seen:
continue
seen.add(word)
words.append(word)
config["words"] = words[:MAX_WORDS]
return config
@@ -312,6 +312,152 @@ def _render_memory(el: dict[str, Any], _children_by_parent: dict[int | None, lis
)
_MOTS_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
_MOTS_DIRECTIONS = (
(0, 1), # horizontale, gauche -> droite
(1, 0), # verticale, haut -> bas
(1, 1), # diagonale droite, haut-gauche -> bas-droite
(1, -1), # diagonale gauche, haut-droite -> bas-gauche
)
_MOTS_MAX_PLACEMENT_ATTEMPTS = 200
_MOTS_MAX_GRID_GROWTH_ATTEMPTS = 20
def _mots_grid_size_for_words(words: list[str]) -> int:
"""Taille de départ de la grille (carrée) — assez grande pour loger le
plus long mot ET laisser assez de cases libres pour le remplissage
aléatoire, sans grille disproportionnée pour une courte liste de mots.
_build_mots_grid grandit cette taille si le placement échoue malgré
tout (mots qui se contraignent mutuellement), donc une estimation
approximative suffit ici."""
from ..labels.mots_config import MIN_WORD_LENGTH
longest = max((len(w) for w in words), default=MIN_WORD_LENGTH)
total_letters = sum(len(w) for w in words)
return max(longest, 8, int(total_letters**0.5) + 2)
def _mots_can_place_word(
grid: list[list[str]], word: str, row: int, col: int, delta_row: int, delta_col: int, size: int
) -> bool:
for i, letter in enumerate(word):
r, c = row + i * delta_row, col + i * delta_col
if not (0 <= r < size and 0 <= c < size):
return False
if grid[r][c] not in ("", letter):
return False
return True
def _mots_place_word(grid: list[list[str]], word: str, size: int) -> list[list[int]] | None:
"""None si aucun emplacement libre n'a été trouvé après le nombre
d'essais autorisé — laisse l'appelant décider (agrandir la grille et
tout retenter, voir _build_mots_grid) plutôt que de placer le mot de
force en écrasant des lettres déjà posées."""
for _ in range(_MOTS_MAX_PLACEMENT_ATTEMPTS):
delta_row, delta_col = random.choice(_MOTS_DIRECTIONS) # nosec B311 # noqa: S311 - placement de mot, jamais crypto # NOSONAR python:S2245
row = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
col = random.randint(0, size - 1) # nosec B311 # noqa: S311 - idem # NOSONAR python:S2245
if not _mots_can_place_word(grid, word, row, col, delta_row, delta_col, size):
continue
cells = []
for i, letter in enumerate(word):
r, c = row + i * delta_row, col + i * delta_col
grid[r][c] = letter
cells.append([r, c])
return cells
return None
def _mots_attempt_placement(words: list[str], size: int) -> dict[str, Any] | None:
grid: list[list[str]] = [["" for _ in range(size)] for _ in range(size)]
placed_words = []
# Les mots les plus longs sont placés en premier : ce sont les plus
# difficiles à caser, autant le faire tant que la grille est encore
# majoritairement libre.
for word in sorted(words, key=len, reverse=True):
cells = _mots_place_word(grid, word, size)
if cells is None:
return None
placed_words.append({"text": word, "cells": cells})
for r in range(size):
for c in range(size):
if not grid[r][c]:
grid[r][c] = random.choice(_MOTS_ALPHABET) # nosec B311 # noqa: S311 - lettre de remplissage, jamais crypto # NOSONAR python:S2245
return {"size": size, "grid": grid, "words": placed_words}
def _build_mots_grid(words: list[str]) -> dict[str, Any]:
"""Construit la grille ET la position exacte de chaque mot (jamais
recalculée côté client, voir _render_mots_player) : une grille carrée,
chaque mot placé horizontalement/verticalement/en diagonale (deux sens
de diagonale seulement, jamais à l'envers — voir _MOTS_DIRECTIONS),
les cases restantes remplies de lettres aléatoires. Si un mot ne
trouve pas sa place (mots qui se contraignent mutuellement), la
grille entière est agrandie et le placement retenté depuis zéro,
plutôt que d'abandonner silencieusement ce mot."""
if not words:
return {"size": 0, "grid": [], "words": []}
size = _mots_grid_size_for_words(words)
for _ in range(_MOTS_MAX_GRID_GROWTH_ATTEMPTS):
result = _mots_attempt_placement(words, size)
if result is not None:
return result
size += 2
# Filet de sécurité théorique : avec MAX_WORDS=10 mots de
# MAX_WORD_LENGTH=20 lettres au plus, la grille finit toujours par
# être assez grande pour tous les loger bien avant cette limite.
return _mots_attempt_placement(words, size) or {"size": size, "grid": [], "words": []}
def _render_mots_player(config: dict[str, Any]) -> str:
"""Grille de mots mêlés RÉELLEMENT interactive — affichée uniquement
en Mode Aperçu, même principe que les autres mini-jeux : la grille ET
la position exacte de chaque mot sont calculées ICI côté serveur
(_build_mots_grid, jamais recalculées côté client) puis embarquées en
JSON ; static/document/js/document-editor.js compare les coordonnées
de la sélection de l'apprenant aux coordonnées exactes de chaque mot
— jamais une simple comparaison de texte, qui se tromperait sur des
lettres partagées entre deux mots qui se croisent."""
built = _build_mots_grid(config["words"])
config_json = html_lib.escape(json.dumps(built), quote=True)
return (
f'<div class="docMotsPlayer" data-mots-config="{config_json}">'
f'<div class="docAssocCard">'
f'<div class="docQuizKicker">Mots mêlés</div>'
f'<div class="docAssocTitle">Retrouvez chaque mot caché dans la grille</div>'
f'<div class="docAssocMeta"><span class="docMotsProg"></span></div>'
f'<div class="docMotsBoard">'
f'<div class="docMotsGrid"></div>'
f'<div class="docMotsWordList"></div>'
f"</div>"
f'<div class="docMinigameRestartBar">'
f'<button type="button" class="docMotsRestartBtn">Recommencer</button>'
f"</div>"
f"</div></div>"
)
def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
from ..labels.mots_config import sanitize_mots_config
config = sanitize_mots_config(el["attributes"])
theme_color = html_lib.escape(str(config["theme_color"]))
word_count = len(config["words"])
word_label = "mot" if word_count <= 1 else "mots"
player_html = _render_mots_player(config) if config["words"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="mots" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Mots mêlés</span>'
f'<span class="docMinigamePlaceholder">{word_count} {word_label}</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>'
@@ -329,7 +475,7 @@ _RENDERERS = {
"quiz": _render_quiz,
"association": _render_association,
"memory": _render_memory,
"mots": _render_minigame_placeholder,
"mots": _render_mots,
"scenario": _render_minigame_placeholder,
"zones": _render_minigame_placeholder,
}
+23 -2
View File
@@ -77,7 +77,28 @@ regroupement à chaque appel.
Comme l'Association, le plateau reste affiché une fois toutes les
paires trouvées (ou toutes les cartes révélées en mode `"single"`) :
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
- **Autres mini-jeux** (`mots`/`scenario`/`zones`) : carte placeholder
portant le libellé du type (voir
- **Mots mêlés** : toujours une carte résumant la config réelle (nombre
de mots) — sanitizée (`sanitize_mots_config`) avant lecture. Si au
moins un mot existe, s'y ajoute (fonction privée `_render_mots_player`)
la grille RÉELLE et interactive. La grille ET la position exacte de
chaque mot sont calculées ICI côté serveur (`_build_mots_grid`, jamais
recalculées côté client) : chaque mot est placé horizontalement,
verticalement, ou en diagonale (haut-gauche→bas-droite ou
haut-droite→bas-gauche — jamais à l'envers), les lettres restantes
tirées au hasard (`random.choice`/`random.randint`, tirage de jeu —
voir `CODE_QUALITY.md`) ; si un mot ne trouve pas sa place, la grille
entière est agrandie et le placement retenté depuis zéro, plutôt que
d'abandonner silencieusement ce mot. Le tout (grille + coordonnées de
chaque mot) est embarqué en JSON dans un attribut `data-mots-config`,
échappé pour l'HTML — même principe que les autres mini-jeux, aucun
aller-retour serveur pendant qu'on joue : `static/document/js/
document-editor.js` compare les coordonnées EXACTES sélectionnées par
l'apprenant à celles de chaque mot (jamais une simple comparaison de
texte, qui se tromperait sur des lettres partagées entre deux mots qui
se croisent). Comme l'Association/Memory, la grille reste affichée une
fois tous les mots trouvés : seul le bouton "Recommencer"
(`.docMinigameRestartBar`) apparaît.
- **Autres mini-jeux** (`scenario`/`zones`) : carte placeholder portant
le libellé du type (voir
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
formulaire de contenu dédié hors périmètre de cette passe.
+10 -7
View File
@@ -14,14 +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, 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
Quiz, Association, Memory et Mots mêlés sont les seuls kinds
revalidés côté serveur (sanitize_quiz_config/
sanitize_association_config/sanitize_memory_config/
sanitize_mots_config, même convention que
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste
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 à
de questions/choix, liste de paires, liste de cartes, liste de mots)
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:
@@ -33,6 +34,8 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
attributes = document_engine.sanitize_association_config(attributes)
elif element["kind"] == "memory":
attributes = document_engine.sanitize_memory_config(attributes)
elif element["kind"] == "mots":
attributes = document_engine.sanitize_mots_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),
+84 -9
View File
@@ -500,31 +500,36 @@ img.docImage {
color: var(--doc-muted);
}
/* ---- 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. ---- */
/* ---- Quiz/Association/Memory/Mots mêlés : 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,
.docMemoryPlayer {
.docMemoryPlayer,
.docMotsPlayer {
display: none;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge {
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge,
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge {
display: none;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer {
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer,
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer {
display: block;
}
.docEditor3.docEditor3--preview [data-kind="quiz"],
.docEditor3.docEditor3--preview [data-kind="association"],
.docEditor3.docEditor3--preview [data-kind="memory"] {
.docEditor3.docEditor3--preview [data-kind="memory"],
.docEditor3.docEditor3--preview [data-kind="mots"] {
border: none;
background: transparent;
}
@@ -534,7 +539,9 @@ img.docImage {
.docEditor3.docEditor3--preview .docAssocPlayer,
.docEditor3.docEditor3--preview .docAssocPlayer *,
.docEditor3.docEditor3--preview .docMemoryPlayer,
.docEditor3.docEditor3--preview .docMemoryPlayer * {
.docEditor3.docEditor3--preview .docMemoryPlayer *,
.docEditor3.docEditor3--preview .docMotsPlayer,
.docEditor3.docEditor3--preview .docMotsPlayer * {
/* .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
@@ -989,6 +996,74 @@ img.docImage {
color: var(--doc-quiz-success-text);
}
/* ---- Mots mêlés : grille interactive (voir docs/plan/PLAN.md, "mots
cachés horizontalement/verticalement/en diagonale"). Réutilise
.docAssocCard/.docAssocTitle/.docAssocMeta/.docMinigameRestartBar
(mêmes rôles visuels que l'Association/Memory ci-dessus). ---- */
.docMotsBoard {
display: flex;
flex-wrap: wrap;
gap: 24px;
align-items: flex-start;
}
.docMotsGrid {
display: grid;
gap: 3px;
width: min(100%, 420px);
user-select: none;
}
.docMotsCell {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--doc-border);
background: var(--doc-bg-2);
color: var(--doc-text);
font-weight: 700;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
}
.docMotsCell.is-selecting {
border-color: var(--doc-accent);
background: rgb(255 95 46 / 18%);
}
.docMotsCell.is-found {
border-color: var(--doc-quiz-success-border);
background: var(--doc-quiz-success-bg);
color: var(--doc-quiz-success-text);
cursor: default;
}
.docMotsWordList {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 140px;
}
.docMotsWordChip {
padding: 8px 12px;
border: 1px solid var(--doc-border);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--doc-text);
background: var(--doc-bg-2);
}
.docMotsWordChip.is-found {
border-color: var(--doc-quiz-success-border);
background: var(--doc-quiz-success-bg);
color: var(--doc-quiz-success-text);
text-decoration: line-through;
}
.docUnknown {
font-size: 12.5px;
color: var(--doc-muted);
+289
View File
@@ -478,6 +478,7 @@ function forgeDocBindCanvasInteractions() {
forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers();
forgeDocInitMemoryPlayers();
forgeDocInitMotsPlayers();
}
/* ---------------------------------------------------------------------
@@ -915,6 +916,225 @@ function forgeDocInitMemoryPlayers() {
});
}
/* ---------------------------------------------------------------------
* Mots mêlés — grille RÉELLEMENT interactive en Mode Aperçu : la grille
* ET la position exacte de chaque mot sont calculées côté serveur (voir
* document_engine/rendering/render_document_element.py::_build_mots_grid,
* jamais recalculées ici) et embarquées en JSON. Une sélection est
* validée par comparaison de COORDONNÉES exactes avec celles d'un mot
* (jamais une comparaison de texte, qui se tromperait sur des lettres
* partagées entre deux mots qui se croisent). Deux façons de
* sélectionner, toutes deux réelles : glisser (mousedown puis survol des
* cases), ou cliquer une case de départ puis une case d'arrivée (repli
* pour les appareils sans glisser fiable — même principe que
* l'Association). État tenu en mémoire, jamais persisté.
* ------------------------------------------------------------------- */
function forgeDocMotsPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.motsConfig || '{}');
return {
size: Number(config.size) || 0,
grid: Array.isArray(config.grid) ? config.grid : [],
words: Array.isArray(config.words) ? config.words : [],
};
} catch (e) {
return { size: 0, grid: [], words: [] };
}
}
function forgeDocMotsCellsBetween(start, end) {
const rowDiff = end.row - start.row;
const colDiff = end.col - start.col;
// Seules 4 directions sont autorisées à la pose (voir _build_mots_grid) :
// horizontale, verticale, ou une vraie diagonale (même distance en
// ligne qu'en colonne) — tout le reste n'est pas une sélection valide.
if (rowDiff !== 0 && colDiff !== 0 && Math.abs(rowDiff) !== Math.abs(colDiff)) return null;
const steps = Math.max(Math.abs(rowDiff), Math.abs(colDiff));
const stepRow = steps === 0 ? 0 : rowDiff / steps;
const stepCol = steps === 0 ? 0 : colDiff / steps;
const cells = [];
for (let i = 0; i <= steps; i += 1) {
cells.push({ row: start.row + stepRow * i, col: start.col + stepCol * i });
}
return cells;
}
function forgeDocMotsCellsMatchWord(cells, wordCells) {
if (cells.length !== wordCells.length) return false;
const forward = cells.every((c, i) => c.row === wordCells[i][0] && c.col === wordCells[i][1]);
if (forward) return true;
// L'apprenant peut glisser dans les deux sens d'un même mot placé
// (départ->fin ou fin->départ) : seul le SENS DE POSE (voir
// _build_mots_grid, jamais à l'envers) est contraint, pas le sens de
// lecture de la sélection.
const reversed = [...wordCells].reverse();
return cells.every((c, i) => c.row === reversed[i][0] && c.col === reversed[i][1]);
}
function forgeDocMotsUpdateProgress(playerEl, state) {
playerEl.querySelector('.docMotsProg').textContent = `${state.found.size} / ${state.words.length} mots trouvés`;
}
function forgeDocMotsCheckComplete(playerEl, state) {
if (state.found.size < state.words.length) return;
// Même choix que l'Association/Memory ci-dessus : la grille reste
// affichée une fois tous les mots trouvés, seul ce bouton apparaît.
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
}
function forgeDocMotsClearSelection(state) {
state.selectedCellEls.forEach((cellEl) => cellEl.classList.remove('is-selecting'));
state.selectedCellEls = [];
}
function forgeDocMotsHighlightPath(state, start, end) {
forgeDocMotsClearSelection(state);
const cells = forgeDocMotsCellsBetween(start, end);
if (!cells) return;
cells.forEach(({ row, col }) => {
const cellEl = state.cellEls[row]?.[col];
if (cellEl) {
cellEl.classList.add('is-selecting');
state.selectedCellEls.push(cellEl);
}
});
}
function forgeDocMotsTryMatch(playerEl, state, cells) {
const matchIndex = state.words.findIndex((w, i) => !state.found.has(i) && forgeDocMotsCellsMatchWord(cells, w.cells));
forgeDocMotsClearSelection(state);
if (matchIndex === -1) return;
state.found.add(matchIndex);
cells.forEach(({ row, col }) => {
const cellEl = state.cellEls[row]?.[col];
if (cellEl) cellEl.classList.add('is-found');
});
const chip = playerEl.querySelector(`.docMotsWordChip[data-word-index="${matchIndex}"]`);
if (chip) chip.classList.add('is-found');
forgeDocMotsUpdateProgress(playerEl, state);
forgeDocMotsCheckComplete(playerEl, state);
}
function forgeDocMotsFinishSelection(playerEl, state) {
state.dragging = false;
if (!state.start || !state.current) {
state.start = null;
state.current = null;
return;
}
const cells = forgeDocMotsCellsBetween(state.start, state.current);
state.start = null;
state.current = null;
if (!cells) {
forgeDocMotsClearSelection(state);
return;
}
forgeDocMotsTryMatch(playerEl, state, cells);
}
let forgeDocMotsGlobalMouseupBound = false;
function forgeDocMotsBindGlobalMouseup() {
// Un SEUL listener document-level pour toute la page (jamais un par
// joueur/par restart) : forgeDocInitMotsPlayers est réappelé à chaque
// rafraîchissement du canevas (voir forgeDocBindCanvasInteractions),
// un listener posé à chaque appel s'accumulerait indéfiniment sur
// `document`, qui n'est lui jamais recréé.
if (forgeDocMotsGlobalMouseupBound) return;
forgeDocMotsGlobalMouseupBound = true;
document.addEventListener('mouseup', () => {
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
const state = playerEl.forgeDocMotsState;
if (state && state.dragging) forgeDocMotsFinishSelection(playerEl, state);
});
});
}
function forgeDocRenderMotsBoard(playerEl, state) {
const gridEl = playerEl.querySelector('.docMotsGrid');
const listEl = playerEl.querySelector('.docMotsWordList');
gridEl.innerHTML = '';
gridEl.style.gridTemplateColumns = `repeat(${state.size}, 1fr)`;
state.cellEls = [];
for (let r = 0; r < state.size; r += 1) {
const rowEls = [];
for (let c = 0; c < state.size; c += 1) {
const cellEl = document.createElement('div');
cellEl.className = 'docMotsCell';
cellEl.textContent = state.grid[r][c];
cellEl.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
state.dragging = true;
state.start = { row: r, col: c };
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
});
cellEl.addEventListener('mouseenter', () => {
if (!state.dragging || !state.start) return;
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
});
cellEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!state.start) {
state.start = { row: r, col: c };
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
return;
}
state.current = { row: r, col: c };
forgeDocMotsFinishSelection(playerEl, state);
});
rowEls.push(cellEl);
gridEl.appendChild(cellEl);
}
state.cellEls.push(rowEls);
}
listEl.innerHTML = '';
state.words.forEach((w, i) => {
const chip = document.createElement('span');
chip.className = `docMotsWordChip${state.found.has(i) ? ' is-found' : ''}`;
chip.dataset.wordIndex = String(i);
chip.textContent = w.text;
listEl.appendChild(chip);
});
}
function forgeDocMotsRestart(playerEl) {
const data = forgeDocMotsPlayerData(playerEl);
const state = {
size: data.size,
grid: data.grid,
words: data.words,
found: new Set(),
start: null,
current: null,
dragging: false,
cellEls: [],
selectedCellEls: [],
};
playerEl.forgeDocMotsState = state;
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
forgeDocMotsUpdateProgress(playerEl, state);
forgeDocRenderMotsBoard(playerEl, state);
}
function forgeDocInitMotsPlayers() {
forgeDocMotsBindGlobalMouseup();
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
const data = forgeDocMotsPlayerData(playerEl);
if (!data.words.length) return;
forgeDocMotsRestart(playerEl);
playerEl.querySelector('.docMotsRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMotsRestart(playerEl);
});
});
}
function forgeDocSetActiveBottomNav(target) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
@@ -1449,6 +1669,74 @@ function forgeDocRenderMemoryProps(panel, el) {
});
}
function forgeDocRenderMotsWordHtml(word, wIndex) {
return `
<div class="docAssocPairRow docMotsWordRow" data-word-index="${wIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Mot ${wIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-word-index="${wIndex}" title="Supprimer ce mot">🗑</button>
</div>
<input type="text"
class="docAssocPairInput docMotsWordInput"
placeholder="Mot à cacher dans la grille"
maxlength="20"
value="${forgeDocEscapeHtml(word)}"
data-word-index="${wIndex}">
</div>
`;
}
function forgeDocRenderMotsProps(panel, el) {
const a = el.attributes;
const words = a.words || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('mots mêlés')}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMotsColor">
${['#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">Mots à cacher (5 à 10 recommandés)</div>
<div id="docMotsWordsList">${words.length ? words.map(forgeDocRenderMotsWordHtml).join('') : '<div class="docEmptyState">Aucun mot — ajoute le premier ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMotsAddWord">+ Ajouter un mot</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMotsColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docMotsAddWord').addEventListener('click', () => {
// Mot par défaut non vide (jamais ""), même leçon que
// forgeDocAssociationNewPair/forgeDocMemoryNewCard : sanitize_mots_config
// (routes/document/document_element_update.py) rejette silencieusement
// tout mot qui ne contient aucune lettre exploitable une fois nettoyé.
patch({ words: [...words, 'NOUVEAU'] });
});
panel.querySelectorAll('#docMotsWordsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const wIndex = Number(btn.dataset.wordIndex);
patch({ words: words.filter((_, i) => i !== wIndex) });
});
});
panel.querySelectorAll('#docMotsWordsList .docMotsWordInput').forEach((input) => {
input.addEventListener('change', (e) => {
const wIndex = Number(input.dataset.wordIndex);
patch({ words: words.map((w, i) => (i === wIndex ? e.target.value : w)) });
});
});
}
function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
@@ -1463,6 +1751,7 @@ function forgeDocRenderProps(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 if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');
+122
View File
@@ -0,0 +1,122 @@
"""Modèle de données du mini-jeu Mots mêlés
(document_engine/labels/mots_config.py) — sans Flask, teste directement
sanitize_mots_config, la construction de la grille
(_build_mots_grid, via render_document_element), et le rendu du plateau."""
import json
from typing import Any
import document_engine as doc_engine
from document_engine.rendering.render_document_element import _build_mots_grid
def test_sanitize_mots_config_on_missing_input_returns_full_defaults() -> None:
assert doc_engine.sanitize_mots_config(None) == {"theme_color": "#ff5f2e", "words": []}
def test_sanitize_mots_config_keeps_valid_words_uppercased() -> None:
raw = {"theme_color": "#123456", "words": ["chat", "chien"]}
config = doc_engine.sanitize_mots_config(raw)
assert config["theme_color"] == "#123456"
assert config["words"] == ["CHAT", "CHIEN"]
def test_sanitize_mots_config_strips_accents_and_non_letters() -> None:
raw = {"words": ["éléphant", "un chat !", "12"]}
config = doc_engine.sanitize_mots_config(raw)
assert config["words"] == ["ELEPHANT", "UNCHAT"]
def test_sanitize_mots_config_drops_words_shorter_than_min_length() -> None:
raw = {"words": ["a", "ok"]}
assert doc_engine.sanitize_mots_config(raw)["words"] == ["OK"]
def test_sanitize_mots_config_deduplicates_words() -> None:
raw = {"words": ["chat", "CHAT", "Chat"]}
assert doc_engine.sanitize_mots_config(raw)["words"] == ["CHAT"]
def test_sanitize_mots_config_caps_words_at_max() -> None:
alphabet = "abcdefghijklmnop"
raw = {"words": [f"mot{alphabet[i]}" for i in range(doc_engine.MAX_WORDS + 3)]}
config = doc_engine.sanitize_mots_config(raw)
assert len(config["words"]) == doc_engine.MAX_WORDS
def test_sanitize_mots_config_ignores_garbage_top_level_input() -> None:
assert doc_engine.sanitize_mots_config("n'importe quoi") == doc_engine.DEFAULT_MOTS_CONFIG
def _mots_element(attributes: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "kind": "mots", "parent_id": None, "order_index": 0, "attributes": attributes}
def test_render_mots_without_words_shows_only_the_badge() -> None:
html = doc_engine.render_document_element(_mots_element(doc_engine.DEFAULT_MOTS_CONFIG), {})
assert "0 mot" in html
assert "docMotsPlayer" not in html
def test_render_mots_with_words_includes_the_interactive_grid() -> None:
config = doc_engine.sanitize_mots_config({"words": ["chat", "chien", "lapin", "oiseau", "poisson"]})
html = doc_engine.render_document_element(_mots_element(config), {})
assert "docMotsPlayer" in html
assert "data-mots-config=" in html
assert "5 mots" in html
def test_render_mots_escapes_word_text_in_embedded_json() -> None:
# Un mot ne peut de toute façon contenir que des lettres A-Z une fois
# sanitizé (voir sanitize_mots_config) : rien à échapper dans le texte
# du mot lui-même. Le seul contenu utilisateur qui atteint réellement
# l'attribut embarqué est donc déjà sûr par construction — ce test
# vérifie que l'échappement JSON standard reste bien en place (guillemets).
config = doc_engine.sanitize_mots_config({"words": ["chat", "chien"]})
html = doc_engine.render_document_element(_mots_element(config), {})
start = html.find('data-mots-config="') + len('data-mots-config="')
end = html.find('"', start)
embedded = json.loads(html[start:end].replace("&quot;", '"'))
assert {"CHAT", "CHIEN"} <= {w["text"] for w in embedded["words"]}
def _cells_spell(grid: list[list[str]], cells: list[list[int]]) -> str:
return "".join(grid[r][c] for r, c in cells)
def test_build_mots_grid_places_every_word_along_its_own_cells() -> None:
words = ["CHAT", "CHIEN", "OISEAU", "POISSON", "LAPIN"]
built = _build_mots_grid(words)
size = built["size"]
grid = built["grid"]
assert len(grid) == size
assert all(len(row) == size for row in grid)
placed = {w["text"] for w in built["words"]}
assert placed == set(words)
for w in built["words"]:
assert _cells_spell(grid, w["cells"]) == w["text"]
def test_build_mots_grid_only_uses_the_four_allowed_directions() -> None:
words = ["CHAT", "CHIEN", "OISEAU", "POISSON", "LAPIN", "SOURIS"]
built = _build_mots_grid(words)
allowed = {(0, 1), (1, 0), (1, 1), (1, -1)}
for w in built["words"]:
cells = w["cells"]
if len(cells) < 2:
continue
delta_row = cells[1][0] - cells[0][0]
delta_col = cells[1][1] - cells[0][1]
assert (delta_row, delta_col) in allowed
for i in range(len(cells) - 1):
assert cells[i + 1][0] - cells[i][0] == delta_row
assert cells[i + 1][1] - cells[i][1] == delta_col
def test_build_mots_grid_fills_every_remaining_cell_with_a_letter() -> None:
built = _build_mots_grid(["CHAT", "CHIEN"])
assert all(cell for row in built["grid"] for cell in row)
def test_build_mots_grid_on_empty_words_returns_an_empty_grid() -> None:
assert _build_mots_grid([]) == {"size": 0, "grid": [], "words": []}