From d8be80ebd1fc61465a716fa330a9f31a2ffd5804 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 20 Sep 2026 15:31:46 +0200 Subject: [PATCH] Implemente le mini-jeu Mots meles (grille reelle, placement 4 directions) 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 --- CODE_QUALITY.md | 2 +- document_engine/__init__.py | 10 + document_engine/labels/element_kind_labels.py | 4 + document_engine/labels/labels.md | 40 +++ document_engine/labels/mots_config.py | 59 ++++ .../rendering/render_document_element.py | 148 ++++++++- document_engine/rendering/rendering.md | 25 +- routes/document/document_element_update.py | 17 +- static/document/document-editor.css | 93 +++++- static/document/js/document-editor.js | 289 ++++++++++++++++++ tests/document/test_mots_config.py | 122 ++++++++ 11 files changed, 789 insertions(+), 20 deletions(-) create mode 100644 document_engine/labels/mots_config.py create mode 100644 tests/document/test_mots_config.py diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index e85b0e9a..07ebc6df 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` (×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 | diff --git a/document_engine/__init__.py b/document_engine/__init__.py index 3a866686..be75403d 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -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", ] diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index 8a6271c5..fb6d4768 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -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") — diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index 6fd9efd7..3876f5bc 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -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. diff --git a/document_engine/labels/mots_config.py b/document_engine/labels/mots_config.py new file mode 100644 index 00000000..1dc0d570 --- /dev/null +++ b/document_engine/labels/mots_config.py @@ -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 diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index a25f1ca9..507d4010 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -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'
' + f'
' + f'
Mots mêlés
' + f'
Retrouvez chaque mot caché dans la grille
' + f'
' + f'
' + f'
' + f'
' + f"
" + f'
' + f'' + f"
" + f"
" + ) + + +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'
' + f'
' + f'Mots mêlés' + f'{word_count} {word_label}' + 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"])}
' @@ -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, } diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index c64992a6..c27bf369 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -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. diff --git a/routes/document/document_element_update.py b/routes/document/document_element_update.py index 203b0e8f..66d173a3 100644 --- a/routes/document/document_element_update.py +++ b/routes/document/document_element_update.py @@ -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), diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 2be08b99..669fef02 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -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); diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index 44aa8ec2..e1cd7384 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -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 ` +
+
+ Mot ${wIndex + 1} + +
+ +
+ `; +} + +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')} +
+ Couleur du thème +
+ ${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `
`).join('')} +
+
+
Mots à cacher (5 à 10 recommandés)
+
${words.length ? words.map(forgeDocRenderMotsWordHtml).join('') : '
Aucun mot — ajoute le premier ci-dessous.
'}
+ + ${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'); diff --git a/tests/document/test_mots_config.py b/tests/document/test_mots_config.py new file mode 100644 index 00000000..46f51cd3 --- /dev/null +++ b/tests/document/test_mots_config.py @@ -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(""", '"')) + 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": []}