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
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:
co-authored by
Claude Sonnet 5
parent
1b1307bb85
commit
d8be80ebd1
+1
-1
@@ -129,7 +129,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq
|
|||||||
| Site(s) | Outil / règle | Raison | Contexte |
|
| Site(s) | Outil / règle | Raison | Contexte |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
| `core/flask_app.py:22` | `python:S4502` (Sonar) | CSRF géré par `core/csrf_guard.py` — garde maison globale (`@app.before_request`), testée dans `test_csrf.py`, jamais Flask-WTF. Sonar ne reconnaît pas cette implémentation custom. | Phase 3 |
|
||||||
| `game_engine/data_actions/compute_operation.py` (×2), `static/game/js/play/offline/compute-operation.js` (×2), `static/game/js/scenes/collision-rules-editor.js` (×2), `static/game/js/triggers/trigger-editor.js`, `document_engine/rendering/render_document_element.py` (×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 |
|
| `static/game/js/play/offline/xapi-client.js` (18 sites) + `static/game/js/play/offline/__tests__/xapi-client.test.js` (2 sites) | `javascript:S5332` | Identifiants du vocabulaire xAPI standard ADL (`http://adlnet.gov/expapi/...`), jamais déréférencés en réseau — simples chaînes comparées/embarquées, le `http://` fait partie du texte fixé par la spec. Le vrai endpoint réseau (`config.endpoint`) est toujours saisi par le créateur, jamais un littéral de ce fichier. | Phase 3 |
|
||||||
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
|
| `publish/scorm_manifest.py` | `B406` (Bandit) | Seul fichier du dépôt qui touche du XML — uniquement en génération (`xml.sax.saxutils.escape`), jamais en parsing d'XML externe. | Phase 3 |
|
||||||
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
|
| `scripts/game/build_demo_dialogues.py:61-63` | `python:S8371` (Sonar) | Accès direct `resp.headers["Location"]` volontaire : script d'usage unique jamais exécuté en production, un `KeyError` cru est un échec au moins aussi clair qu'un `.get()` renvoyant `None`. | Phase 3 |
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ from .labels.memory_config import (
|
|||||||
MIN_CARDS,
|
MIN_CARDS,
|
||||||
sanitize_memory_config,
|
sanitize_memory_config,
|
||||||
)
|
)
|
||||||
|
from .labels.mots_config import (
|
||||||
|
DEFAULT_MOTS_CONFIG,
|
||||||
|
MAX_WORDS,
|
||||||
|
MIN_WORDS,
|
||||||
|
sanitize_mots_config,
|
||||||
|
)
|
||||||
from .labels.quiz_config import (
|
from .labels.quiz_config import (
|
||||||
DEFAULT_QUIZ_CONFIG,
|
DEFAULT_QUIZ_CONFIG,
|
||||||
MAX_CHOICES,
|
MAX_CHOICES,
|
||||||
@@ -64,6 +70,7 @@ __all__ = [
|
|||||||
"CONTENT_KINDS",
|
"CONTENT_KINDS",
|
||||||
"DEFAULT_ASSOCIATION_CONFIG",
|
"DEFAULT_ASSOCIATION_CONFIG",
|
||||||
"DEFAULT_MEMORY_CONFIG",
|
"DEFAULT_MEMORY_CONFIG",
|
||||||
|
"DEFAULT_MOTS_CONFIG",
|
||||||
"DEFAULT_QUIZ_CONFIG",
|
"DEFAULT_QUIZ_CONFIG",
|
||||||
"ELEMENT_KIND_LABELS",
|
"ELEMENT_KIND_LABELS",
|
||||||
"ELEMENT_LIBRARY",
|
"ELEMENT_LIBRARY",
|
||||||
@@ -71,10 +78,12 @@ __all__ = [
|
|||||||
"MAX_CHOICES",
|
"MAX_CHOICES",
|
||||||
"MAX_PAIRS",
|
"MAX_PAIRS",
|
||||||
"MAX_TIMER_SECONDS",
|
"MAX_TIMER_SECONDS",
|
||||||
|
"MAX_WORDS",
|
||||||
"MIN_CARDS",
|
"MIN_CARDS",
|
||||||
"MIN_CHOICES",
|
"MIN_CHOICES",
|
||||||
"MIN_PAIRS",
|
"MIN_PAIRS",
|
||||||
"MIN_TIMER_SECONDS",
|
"MIN_TIMER_SECONDS",
|
||||||
|
"MIN_WORDS",
|
||||||
"MINIGAME_KINDS",
|
"MINIGAME_KINDS",
|
||||||
"SHAPE_KINDS",
|
"SHAPE_KINDS",
|
||||||
"add_document_element",
|
"add_document_element",
|
||||||
@@ -88,6 +97,7 @@ __all__ = [
|
|||||||
"render_document_element",
|
"render_document_element",
|
||||||
"sanitize_association_config",
|
"sanitize_association_config",
|
||||||
"sanitize_memory_config",
|
"sanitize_memory_config",
|
||||||
|
"sanitize_mots_config",
|
||||||
"sanitize_quiz_config",
|
"sanitize_quiz_config",
|
||||||
"update_document_element_attributes",
|
"update_document_element_attributes",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import Any
|
|||||||
|
|
||||||
from .association_config import DEFAULT_ASSOCIATION_CONFIG
|
from .association_config import DEFAULT_ASSOCIATION_CONFIG
|
||||||
from .memory_config import DEFAULT_MEMORY_CONFIG
|
from .memory_config import DEFAULT_MEMORY_CONFIG
|
||||||
|
from .mots_config import DEFAULT_MOTS_CONFIG
|
||||||
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
from .quiz_config import DEFAULT_QUIZ_CONFIG
|
||||||
|
|
||||||
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
|
||||||
@@ -90,6 +91,9 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
|||||||
if kind == "memory":
|
if kind == "memory":
|
||||||
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
|
# Même raison de copie que "quiz"/"association" ci-dessus (voir memory_config.py).
|
||||||
return {**DEFAULT_MEMORY_CONFIG, "cards": list(DEFAULT_MEMORY_CONFIG["cards"])}
|
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:
|
if kind in MINIGAME_KINDS:
|
||||||
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
# Panneau Propriétés minimal (voir PLAN.md §3.5, dernier
|
||||||
# paragraphe : "état par défaut en attendant sa spécification") —
|
# paragraphe : "état par défaut en attendant sa spécification") —
|
||||||
|
|||||||
@@ -139,3 +139,43 @@ carte invalide est silencieusement supprimée de la liste. La liste finale
|
|||||||
est tronquée à `MAX_CARDS`.
|
est tronquée à `MAX_CARDS`.
|
||||||
- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_MEMORY_CONFIG`).
|
||||||
- **Exceptions** : aucune.
|
- **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.
|
||||||
|
|||||||
@@ -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:
|
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
|
||||||
|
|
||||||
@@ -329,7 +475,7 @@ _RENDERERS = {
|
|||||||
"quiz": _render_quiz,
|
"quiz": _render_quiz,
|
||||||
"association": _render_association,
|
"association": _render_association,
|
||||||
"memory": _render_memory,
|
"memory": _render_memory,
|
||||||
"mots": _render_minigame_placeholder,
|
"mots": _render_mots,
|
||||||
"scenario": _render_minigame_placeholder,
|
"scenario": _render_minigame_placeholder,
|
||||||
"zones": _render_minigame_placeholder,
|
"zones": _render_minigame_placeholder,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,28 @@ regroupement à chaque appel.
|
|||||||
Comme l'Association, le plateau reste affiché une fois toutes les
|
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"`) :
|
paires trouvées (ou toutes les cartes révélées en mode `"single"`) :
|
||||||
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
|
seul le bouton "Recommencer" (`.docMinigameRestartBar`) apparaît.
|
||||||
- **Autres mini-jeux** (`mots`/`scenario`/`zones`) : carte placeholder
|
- **Mots mêlés** : toujours une carte résumant la config réelle (nombre
|
||||||
portant le libellé du type (voir
|
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é,
|
`document_engine/labels/element_kind_labels.py`) — emplacement réservé,
|
||||||
formulaire de contenu dédié hors périmètre de cette passe.
|
formulaire de contenu dédié hors périmètre de cette passe.
|
||||||
|
|||||||
@@ -14,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
|
Propriétés (voir static/document/js/document-editor.js) envoie
|
||||||
systématiquement l'état complet de ses champs.
|
systématiquement l'état complet de ses champs.
|
||||||
|
|
||||||
Quiz, Association et Memory sont les seuls kinds revalidés côté
|
Quiz, Association, Memory et Mots mêlés sont les seuls kinds
|
||||||
serveur (sanitize_quiz_config/sanitize_association_config/
|
revalidés côté serveur (sanitize_quiz_config/
|
||||||
sanitize_memory_config, même convention que
|
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
|
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste
|
||||||
de questions/choix, liste de paires, liste de cartes) doit rester
|
de questions/choix, liste de paires, liste de cartes, liste de mots)
|
||||||
structurellement correcte pour que le rendu ne plante jamais,
|
doit rester structurellement correcte pour que le rendu ne plante
|
||||||
contrairement aux autres kinds (texte/forme/image...) dont les
|
jamais, contrairement aux autres kinds (texte/forme/image...) dont
|
||||||
attributs sont de simples valeurs scalaires sans structure à
|
les attributs sont de simples valeurs scalaires sans structure à
|
||||||
garantir."""
|
garantir."""
|
||||||
element = document_engine.get_document_element(slug, element_id)
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
if element is None:
|
if element is None:
|
||||||
@@ -33,6 +34,8 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
|
|||||||
attributes = document_engine.sanitize_association_config(attributes)
|
attributes = document_engine.sanitize_association_config(attributes)
|
||||||
elif element["kind"] == "memory":
|
elif element["kind"] == "memory":
|
||||||
attributes = document_engine.sanitize_memory_config(attributes)
|
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)
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
element = db.assert_not_none(
|
element = db.assert_not_none(
|
||||||
document_engine.get_document_element(slug, element_id),
|
document_engine.get_document_element(slug, element_id),
|
||||||
|
|||||||
@@ -500,31 +500,36 @@ img.docImage {
|
|||||||
color: var(--doc-muted);
|
color: var(--doc-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Quiz/Association/Memory : mini-jeux interactifs en Mode Aperçu
|
/* ---- Quiz/Association/Memory/Mots mêlés : mini-jeux interactifs en Mode
|
||||||
(voir docs/plan/maquettes/document-formation-web.html — référence
|
Aperçu (voir docs/plan/maquettes/document-formation-web.html —
|
||||||
visuelle du Quiz). Masqués en édition (seul .docMinigameBadge y est
|
référence visuelle du Quiz). Masqués en édition (seul
|
||||||
visible, voir plus haut) ; jamais les deux visibles en même temps. ---- */
|
.docMinigameBadge y est visible, voir plus haut) ; jamais les deux
|
||||||
|
visibles en même temps. ---- */
|
||||||
.docQuizPlayer,
|
.docQuizPlayer,
|
||||||
.docAssocPlayer,
|
.docAssocPlayer,
|
||||||
.docMemoryPlayer {
|
.docMemoryPlayer,
|
||||||
|
.docMotsPlayer {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
|
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
|
||||||
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
|
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
|
||||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge {
|
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge,
|
||||||
|
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
|
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
|
||||||
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
|
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
|
||||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer {
|
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer,
|
||||||
|
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docEditor3.docEditor3--preview [data-kind="quiz"],
|
.docEditor3.docEditor3--preview [data-kind="quiz"],
|
||||||
.docEditor3.docEditor3--preview [data-kind="association"],
|
.docEditor3.docEditor3--preview [data-kind="association"],
|
||||||
.docEditor3.docEditor3--preview [data-kind="memory"] {
|
.docEditor3.docEditor3--preview [data-kind="memory"],
|
||||||
|
.docEditor3.docEditor3--preview [data-kind="mots"] {
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
@@ -534,7 +539,9 @@ img.docImage {
|
|||||||
.docEditor3.docEditor3--preview .docAssocPlayer,
|
.docEditor3.docEditor3--preview .docAssocPlayer,
|
||||||
.docEditor3.docEditor3--preview .docAssocPlayer *,
|
.docEditor3.docEditor3--preview .docAssocPlayer *,
|
||||||
.docEditor3.docEditor3--preview .docMemoryPlayer,
|
.docEditor3.docEditor3--preview .docMemoryPlayer,
|
||||||
.docEditor3.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]
|
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
|
||||||
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
||||||
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
|
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
|
||||||
@@ -989,6 +996,74 @@ img.docImage {
|
|||||||
color: var(--doc-quiz-success-text);
|
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 {
|
.docUnknown {
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--doc-muted);
|
color: var(--doc-muted);
|
||||||
|
|||||||
@@ -478,6 +478,7 @@ function forgeDocBindCanvasInteractions() {
|
|||||||
forgeDocInitQuizPlayers();
|
forgeDocInitQuizPlayers();
|
||||||
forgeDocInitAssociationPlayers();
|
forgeDocInitAssociationPlayers();
|
||||||
forgeDocInitMemoryPlayers();
|
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) {
|
function forgeDocSetActiveBottomNav(target) {
|
||||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||||
b.classList.toggle('is-active', b.dataset.target === target);
|
b.classList.toggle('is-active', b.dataset.target === target);
|
||||||
@@ -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) {
|
function forgeDocRenderProps(el) {
|
||||||
const panel = document.getElementById('docPropsPanel');
|
const panel = document.getElementById('docPropsPanel');
|
||||||
if (!el) {
|
if (!el) {
|
||||||
@@ -1463,6 +1751,7 @@ function forgeDocRenderProps(el) {
|
|||||||
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
||||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||||
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
||||||
|
else if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
|
||||||
else forgeDocRenderMinigameProps(panel, el);
|
else forgeDocRenderMinigameProps(panel, el);
|
||||||
|
|
||||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||||
|
|||||||
@@ -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": []}
|
||||||
Reference in New Issue
Block a user