Implemente le mini-jeu Scenario (situation, choix, consequences)
Build and deploy / test-python (push) Failing after 59s
Build and deploy / test-js (push) Successful in 50s
Build and deploy / lint-python (push) Failing after 59s
Build and deploy / lint-js (push) Failing after 1m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 59s

Cinquieme mini-jeu du support de formation : le createur ecrit une
situation initiale, definit 2 a 4 choix, indique lequel est le bon, et
redige la consequence de chaque choix. Plusieurs scenarios peuvent etre
crees, joues dans l'ordre d'ecriture (jamais melanges, contrairement a
Association/Memory/Mots meles : ce sont des mises en situation
sequentielles). En Apercu, l'apprenant lit la situation, choisit une
option, decouvre la consequence de SON choix et si c'etait le bon, puis
passe au scenario suivant. Comme Association/Memory/Mots meles, le
dernier scenario reste affiche une fois repondu : seul le bouton
Recommencer apparait, jamais un ecran de resultat separe (reserve au
Quiz). Reutilise les classes visuelles du Quiz (docQuizOptions/
docQuizFeedback/docQuizNextBar) plutot que de dupliquer ces regles.

Verifie via simulation DOM reelle (jsdom) : progression entre plusieurs
scenarios, choix correct/incorrect avec revelation de la bonne reponse,
comportement de fin de partie, panneau Proprietes (ajout/suppression de
scenario, changement du nombre de choix, selection du bon choix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 16:50:10 +02:00
co-authored by Claude Sonnet 5
parent d8be80ebd1
commit b2292de122
10 changed files with 669 additions and 23 deletions
+10
View File
@@ -63,6 +63,12 @@ from .labels.quiz_config import (
quiz_total_points,
sanitize_quiz_config,
)
from .labels.scenario_config import (
DEFAULT_SCENARIO_CONFIG,
MAX_SCENARIO_CHOICES,
MIN_SCENARIO_CHOICES,
sanitize_scenario_config,
)
from .rendering.render_document_element import render_document, render_document_element
__all__ = [
@@ -72,16 +78,19 @@ __all__ = [
"DEFAULT_MEMORY_CONFIG",
"DEFAULT_MOTS_CONFIG",
"DEFAULT_QUIZ_CONFIG",
"DEFAULT_SCENARIO_CONFIG",
"ELEMENT_KIND_LABELS",
"ELEMENT_LIBRARY",
"MAX_CARDS",
"MAX_CHOICES",
"MAX_PAIRS",
"MAX_SCENARIO_CHOICES",
"MAX_TIMER_SECONDS",
"MAX_WORDS",
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
"MIN_SCENARIO_CHOICES",
"MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS",
@@ -99,5 +108,6 @@ __all__ = [
"sanitize_memory_config",
"sanitize_mots_config",
"sanitize_quiz_config",
"sanitize_scenario_config",
"update_document_element_attributes",
]
@@ -9,6 +9,7 @@ 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
from .scenario_config import DEFAULT_SCENARIO_CONFIG
SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait")
CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton")
@@ -94,6 +95,9 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
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 == "scenario":
# Même raison de copie que ci-dessus (voir scenario_config.py).
return {**DEFAULT_SCENARIO_CONFIG, "scenarios": list(DEFAULT_SCENARIO_CONFIG["scenarios"])}
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") —
+38
View File
@@ -179,3 +179,41 @@ supprimé de la liste, de même qu'un doublon exact d'un mot déjà retenu
liste finale est tronquée à `MAX_WORDS`.
- **Retour** : dict complet (mêmes clés que `DEFAULT_MOTS_CONFIG`).
- **Exceptions** : aucune.
## `scenario_config.py` — modèle de données du mini-jeu Scénario
Cinquième mini-jeu implémenté : l'apprenant lit une situation initiale,
choisit une option parmi 2 à 4, puis découvre la conséquence de SON
choix ainsi que s'il s'agissait du bon choix (voir
`document_engine/rendering/render_document_element.py`::
`_render_scenario_player`). Le créateur peut définir plusieurs scénarios,
joués les uns après les autres dans l'ORDRE d'écriture (jamais mélangés,
contrairement à Association/Memory/Mots mêlés). Même convention que
`quiz_config.py`.
### `MIN_SCENARIO_CHOICES`, `MAX_SCENARIO_CHOICES: int`
Bornes de validation (`2`/`4` choix par scénario). Nommées explicitement
"SCENARIO" (pas juste `MIN_CHOICES`/`MAX_CHOICES`) pour éviter toute
collision avec les constantes de même nom de `quiz_config.py`, qui
bornent un concept différent (le nombre de réponses d'une question de
quiz) — `document_engine/__init__.py` ré-exporte l'intégralité de l'API
publique du paquet à plat, un nom générique entrerait en collision.
### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "scenarios": []}`.
### `sanitize_scenario_config(raw_config: Any) -> dict[str, Any]`
Valide/nettoie une config de scénario arbitraire (JSON venu du client) —
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
`DEFAULT_SCENARIO_CONFIG`. Chaque scénario de `raw_config["scenarios"]`
est validé indépendamment (voir `_sanitize_scenario`/
`_sanitize_scenario_choice`, privées) : la `situation` doit être non
vide une fois `.strip()`-ée, et il doit rester au moins
`MIN_SCENARIO_CHOICES` choix valides (texte non vide) une fois la liste
tronquée à `MAX_SCENARIO_CHOICES` — sinon le scénario entier est
silencieusement supprimé de la liste. La `consequence` d'un choix, elle,
peut rester vide (un créateur peut vouloir la renseigner plus tard sans
que ça invalide le choix). `correct_index` retombe sur `0` s'il est hors
bornes ou absent.
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
- **Exceptions** : aucune.
+75
View File
@@ -0,0 +1,75 @@
"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) —
l'apprenant lit une situation initiale, choisit une option parmi 2 à 4,
puis découvre la conséquence de SON choix ainsi que s'il s'agissait du
bon choix (voir document_engine/rendering/render_document_element.py::
_render_scenario_player). Le créateur peut définir plusieurs scénarios,
joués les uns après les autres dans l'ordre d'écriture. Même convention
resolve_X/sanitize_X que quiz_config.py/association_config.py/
memory_config.py/mots_config.py (aucun import croisé) : sanitize_scenario_
config est pure, ne lève jamais, et renvoie toujours un dict complet."""
from typing import Any
# Nommés explicitement "SCENARIO" (pas juste MIN/MAX_CHOICES) : document_
# engine/__init__.py ré-exporte l'intégralité de l'API publique du paquet
# à plat (voir CLAUDE.md, "1 fichier = 1 fonction publique") — un nom
# générique entrerait en collision avec MIN_CHOICES/MAX_CHOICES de
# quiz_config.py, qui borne un concept différent (le nombre de réponses
# d'une question de quiz, pas le nombre d'options d'une décision).
MIN_SCENARIO_CHOICES = 2
MAX_SCENARIO_CHOICES = 4
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
"theme_color": "#ff5f2e",
"scenarios": [],
}
def _sanitize_scenario_choice(raw: Any) -> dict[str, str] | None:
"""None si le choix est invalide (texte vide) — filtrée par
_sanitize_scenario plutôt que de faire échouer tout le scénario,
même convention que quiz_config.py::_sanitize_question. La
conséquence, elle, peut rester vide (un créateur peut vouloir la
renseigner plus tard sans que ça invalide le choix)."""
if not isinstance(raw, dict):
return None
text = str(raw.get("text", "")).strip()
if not text:
return None
consequence = str(raw.get("consequence", "")).strip()
return {"text": text, "consequence": consequence}
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
"""None si le scénario est invalide (situation vide, ou moins de
MIN_SCENARIO_CHOICES choix non vides) — même convention que
quiz_config.py::_sanitize_question."""
if not isinstance(raw, dict):
return None
situation = str(raw.get("situation", "")).strip()
if not situation:
return None
raw_choices = raw.get("choices")
if not isinstance(raw_choices, list):
return None
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
choices = choices[:MAX_SCENARIO_CHOICES]
if len(choices) < MIN_SCENARIO_CHOICES:
return None
correct_index = raw.get("correct_index")
if not isinstance(correct_index, int) or isinstance(correct_index, bool) or not (0 <= correct_index < len(choices)):
correct_index = 0
return {"situation": situation, "choices": choices, "correct_index": correct_index}
def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]:
config = dict(DEFAULT_SCENARIO_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_scenarios = raw_config.get("scenarios")
if isinstance(raw_scenarios, list):
config["scenarios"] = [s for s in (_sanitize_scenario(item) for item in raw_scenarios) if s is not None]
return config
@@ -458,6 +458,62 @@ def _render_mots(el: dict[str, Any], _children_by_parent: dict[int | None, list[
)
def _render_scenario_player(config: dict[str, Any]) -> str:
"""Mise en situation RÉELLEMENT interactive — affichée uniquement en
Mode Aperçu, même principe que les autres mini-jeux : aucun
aller-retour serveur, tout le déroulé (lecture de la situation, choix,
conséquence révélée, scénario suivant) est géré par
static/document/js/document-editor.js à partir du JSON embarqué. Les
scénarios gardent l'ORDRE d'écriture du créateur (contrairement à
l'Association/Memory/Mots mêlés, jamais mélangés) : ce sont des mises
en situation séquentielles, pas des éléments à faire correspondre ou
retrouver — un mélange n'aurait ici aucun sens pédagogique. Réutilise
les classes visuelles du Quiz (.docQuizOptions/.docQuizFeedback/
.docQuizNextBar/.docQuizQuestionText) plutôt que de dupliquer ces
règles, même esprit que .docAssocCard/.docMinigameRestartBar."""
config_json = html_lib.escape(json.dumps({"scenarios": config["scenarios"]}), quote=True)
return (
f'<div class="docScenarioPlayer" data-scenario-config="{config_json}">'
f'<div class="docAssocCard">'
f'<div class="docQuizKicker">Scénario</div>'
f'<div class="docAssocMeta"><span class="docScenarioProg"></span></div>'
f'<div class="docScenarioSituation docQuizQuestionText"></div>'
f'<div class="docScenarioChoices docQuizOptions"></div>'
f'<div class="docScenarioConsequence docQuizFeedback"></div>'
f'<div class="docQuizNextBar">'
f'<button type="button" class="docQuizNextBtn docScenarioNextBtn">Scénario suivant</button>'
f"</div>"
# Même choix que l'Association/Memory/Mots mêlés : le dernier
# scénario reste affiché une fois répondu, seul ce bouton
# apparaît (jamais un écran de résultat séparé — ça reste le
# comportement du Quiz).
f'<div class="docMinigameRestartBar">'
f'<button type="button" class="docScenarioRestartBtn">Recommencer</button>'
f"</div>"
f"</div></div>"
)
def _render_scenario(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
from ..labels.scenario_config import sanitize_scenario_config
config = sanitize_scenario_config(el["attributes"])
theme_color = html_lib.escape(str(config["theme_color"]))
scenario_count = len(config["scenarios"])
scenario_label = "scénario" if scenario_count <= 1 else "scénarios"
player_html = _render_scenario_player(config) if config["scenarios"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="scenario" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Scénario</span>'
f'<span class="docMinigamePlaceholder">{scenario_count} {scenario_label}</span>'
f"</div>"
f"{player_html}"
f"</div>"
)
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
@@ -476,6 +532,6 @@ _RENDERERS = {
"association": _render_association,
"memory": _render_memory,
"mots": _render_mots,
"scenario": _render_minigame_placeholder,
"scenario": _render_scenario,
"zones": _render_minigame_placeholder,
}
+22 -4
View File
@@ -98,7 +98,25 @@ regroupement à chaque appel.
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.
- **Scénario** : toujours une carte résumant la config réelle (nombre de
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
au moins un scénario existe, s'y ajoute (fonction privée
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
l'apprenant lit la situation, choisit une option, découvre la
conséquence de SON choix et si c'était le bon, puis passe au scénario
suivant. Les scénarios gardent l'ORDRE d'écriture du créateur (jamais
mélangés, contrairement à Association/Memory/Mots mêlés — ce sont des
mises en situation séquentielles, pas des éléments à faire
correspondre/retrouver). Réutilise les classes visuelles du Quiz
(`.docQuizOptions`/`.docQuizFeedback`/`.docQuizNextBar`/
`.docQuizQuestionText`) plutôt que de dupliquer ces règles. Embarqué en
JSON dans un attribut `data-scenario-config`, échappé pour l'HTML —
même principe que les autres mini-jeux, aucun aller-retour serveur
pendant qu'on joue. Comme l'Association/Memory/Mots mêlés (mais
contrairement au Quiz), le dernier scénario reste affiché une fois
répondu : seul le bouton "Recommencer" (`.docMinigameRestartBar`)
apparaît.
- **Autres mini-jeux** (`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.