Refonte du Scenario en arbre de decision (modale dediee, plus de vrai/faux
Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / lint-python (push) Failing after 1m12s
Build and deploy / lint-js (push) Failing after 1m8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s

Retour utilisateur : "une consequence peut mener a d'autres choix et
ainsi de suite, il n'y a pas de notion vrai/faux, il faut une modale
avec la possibilite de construire un veritable arbre de choix
consequence". Remplace le modele plat (situation + 2-4 choix + un seul
bon choix + une consequence terminale) par un vrai graphe de nœuds :
{"title", "nodes": [{"id", "text", "choices": [{"text", "target_id"}]}]}
- nodes[0] est la situation initiale, chaque choix peut pointer vers
n'importe quel autre nœud (branchement, convergence, fins multiples),
un nœud sans choix est une fin de branche valide. Aucune notion de
bonne/mauvaise reponse.

L'arbre se construit desormais dans une modale dediee (trop de
structure pour la colonne etroite du panneau Proprietes) : liste de
nœuds, chaque choix avec un menu deroulant "mene a" listant les autres
nœuds ou "fin de branche". La modale est un composant generique
(.docModal*) independant de tout framework externe.

sanitize_scenario_config degrade silencieusement tout target_id
orphelin (nœud supprime) vers None plutot que de faire echouer le
scenario entier. Le lecteur cote client navigue le graphe nœud par
nœud, le texte du nœud visite remplace le precedent (toujours pas un
Quiz), jusqu'a une fin de branche puis passage au scenario suivant.

Verifie via simulation DOM reelle (jsdom) : navigation ramifiee
(branchement, convergence, fin via nœud vide ET via choix sans cible),
plusieurs arbres a la suite, et l'editeur modal complet (ouverture,
ajout/suppression de nœud avec reparation des references pendantes,
changement de cible, fermeture bouton/fond/Echap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 17:29:24 +02:00
co-authored by Claude Sonnet 5
parent c04a81cfec
commit a529857379
9 changed files with 679 additions and 296 deletions
-2
View File
@@ -66,7 +66,6 @@ from .labels.quiz_config import (
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
@@ -90,7 +89,6 @@ __all__ = [
"MIN_CARDS",
"MIN_CHOICES",
"MIN_PAIRS",
"MIN_SCENARIO_CHOICES",
"MIN_TIMER_SECONDS",
"MIN_WORDS",
"MINIGAME_KINDS",
+43 -20
View File
@@ -183,21 +183,41 @@ liste finale est tronquée à `MAX_WORDS`.
## `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`.
choisit une option, puis découvre la conséquence de SON choix — cette
conséquence peut elle-même mener à de nouveaux choix, et ainsi de suite
(arbre de décision, voir `document_engine/rendering/
render_document_element.py`::`_render_scenario_player`). Aucune notion
de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 : "il n'y a
pas de notion vrai/faux, l'utilisateur observe les conséquences") —
l'apprenant explore simplement les branches jusqu'à une fin. Le
créateur peut définir plusieurs scénarios (plusieurs arbres
indépendants), 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 resolve_X/sanitize_X 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
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
supprimable depuis le panneau Propriétés — voir
`forgeDocRenderScenarioNodeHtml`, static/document/js/document-editor.js).
Chaque nœud est `{"id": str, "text": str, "choices": [...]}` — `id` est
une référence STABLE (générée côté client, jamais recalculée côté
serveur) vers laquelle un choix d'un AUTRE nœud peut pointer via son
`target_id` ; un nœud SANS choix est une fin de branche (état normal,
pas une erreur — contrairement à l'ancien modèle qui exigeait 2 à 4
choix par scénario). Un choix est `{"text": str, "target_id": str |
None}` — `target_id` à `None` signifie "fin de branche à ce choix".
### `MAX_SCENARIO_CHOICES: int`
Borne technique du nombre de choix par nœud (`4`) — évite une UI
disproportionnée, aucun lien avec une notion de bonne/mauvaise réponse.
Nommée explicitement "SCENARIO" (pas juste `MAX_CHOICES`) pour éviter
toute collision avec la constante de même nom de `quiz_config.py`, qui
borne 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.
Contrairement à l'ancien modèle, il n'existe PAS de minimum : un nœud
peut avoir 0 choix (fin de branche), c'est un état valide, pas une
erreur à filtrer.
### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
`{"theme_color": "#ff5f2e", "scenarios": []}`.
@@ -207,13 +227,16 @@ 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.
`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier
est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Le
scénario entier est supprimé s'il ne reste plus aucun nœud valide après
nettoyage (il faut au moins la situation initiale). Une fois l'ensemble
des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
existant (nœud invalide/supprimé) est silencieusement remis à `None`
("fin de branche") plutôt que de faire échouer tout le scénario — même
philosophie que le reste de ce module : ne jamais faire échouer une
structure entière pour une seule référence cassée.
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
- **Exceptions** : aucune.
+79 -36
View File
@@ -1,22 +1,30 @@
"""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."""
l'apprenant lit une situation initiale, choisit une option, et découvre
la conséquence de SON choix ; cette conséquence peut elle-même mener à
de nouveaux choix, et ainsi de suite (arbre de décision, voir
document_engine/rendering/render_document_element.py::
_render_scenario_player). Aucune notion de bonne/mauvaise réponse
(retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux,
l'utilisateur observe les conséquences") — l'apprenant explore
simplement les branches. Le créateur peut définir plusieurs scénarios
(plusieurs arbres indépendants), 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.
Structure d'un scénario : {"title": str, "nodes": [...]}. `nodes[0]` est
TOUJOURS la situation initiale (racine de l'arbre, jamais supprimable
depuis le panneau Propriétés). Chaque nœud est
{"id": str, "text": str, "choices": [...]} — `id` est une référence
STABLE (générée côté client, jamais recalculée ici) vers laquelle un
choix d'un AUTRE nœud peut pointer via son `target_id` ; un nœud sans
choix est une fin de branche. Un choix est {"text": str,
"target_id": str | None} — `target_id` à None signifie "fin de branche
à ce choix" (aucun nœud suivant)."""
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] = {
@@ -25,41 +33,76 @@ DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
}
def _sanitize_scenario_choice(raw: Any) -> dict[str, str] | None:
def _sanitize_scenario_choice(raw: Any) -> dict[str, Any] | 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)."""
_sanitize_scenario_node plutôt que de faire échouer tout le nœud,
même convention que quiz_config.py::_sanitize_question. `target_id`
est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois
l'ensemble des ids valides du scénario connu), jamais ici."""
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}
target_id = raw.get("target_id")
return {"text": text, "target_id": target_id if isinstance(target_id, str) and target_id else None}
def _sanitize_scenario_node(raw: Any) -> dict[str, Any] | None:
"""None si le nœud est invalide (id absent, ou texte vide) — même
convention que les autres sanitize_X de ce module. Un nœud SANS choix
est parfaitement valide (fin de branche), contrairement à l'ancien
modèle où un scénario exigeait 2 à 4 choix : ici, 0 choix est un état
normal, pas une erreur."""
if not isinstance(raw, dict):
return None
node_id = raw.get("id")
if not isinstance(node_id, str) or not node_id:
return None
text = str(raw.get("text", "")).strip()
if not text:
return None
raw_choices = raw.get("choices")
choices = []
if isinstance(raw_choices, list):
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
choices = choices[:MAX_SCENARIO_CHOICES]
return {"id": node_id, "text": text, "choices": choices}
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."""
"""None si le scénario est invalide (titre vide, ou aucun nœud
valide restant après nettoyage — il faut au moins la situation
initiale). Un choix qui pointait vers un nœud devenu invalide/
supprimé dégrade silencieusement vers `target_id: None` ("fin de
branche") plutôt que de faire échouer tout le scénario — même
philosophie que le reste de ce module : ne jamais lever, ne jamais
faire échouer une structure entière pour une seule référence
cassée."""
if not isinstance(raw, dict):
return None
situation = str(raw.get("situation", "")).strip()
if not situation:
title = str(raw.get("title", "")).strip()
if not title:
return None
raw_choices = raw.get("choices")
if not isinstance(raw_choices, list):
raw_nodes = raw.get("nodes")
if not isinstance(raw_nodes, 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:
nodes = []
seen_ids: set[str] = set()
for item in raw_nodes:
node = _sanitize_scenario_node(item)
if node is None or node["id"] in seen_ids:
continue
seen_ids.add(node["id"])
nodes.append(node)
if not nodes:
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}
valid_ids = {n["id"] for n in nodes}
for node in nodes:
node["choices"] = [
{**c, "target_id": c["target_id"] if c["target_id"] in valid_ids else None} for c in node["choices"]
]
return {"title": title, "nodes": nodes}
def sanitize_scenario_config(raw_config: Any) -> dict[str, Any]:
@@ -461,23 +461,27 @@ 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 à
aller-retour serveur, tout le déroulé (navigation dans l'arbre de
décision, scénario suivant) est géré par static/document/js/
document-editor.js à partir du JSON embarqué (voir scenario_config.py
pour la forme exacte d'un scénario : {"title", "nodes"}, nodes[0]
étant la situation initiale). Les scénarios (plusieurs arbres
indépendants) 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. Un seul
bloc de texte (.docScenarioSituation) sert successivement à afficher
la situation PUIS, une fois un choix fait, la conséquence à sa place
(les boutons de choix disparaissent aussi) — volontairement PAS le
comportement du Quiz, où la question reste affichée à côté d'un
encart de feedback séparé (retour utilisateur du 20/09/2026 : "ce
n'est pas un quiz, la conséquence s'affiche à la place de la
situation précédente"). 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."""
le texte de chaque nœud visité : une fois un choix fait, le texte du
nœud suivant REMPLACE le précédent (les boutons de choix disparaissent
avec lui) — volontairement PAS le comportement du Quiz, où la
question resterait affichée à côté d'un encart de feedback séparé.
Aucune notion de bonne/mauvaise réponse ici (retour utilisateur du
20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe
les conséquences") — un nœud sans choix est simplement une fin de
branche. Réutilise les classes visuelles du Quiz
(.docQuizOptions/.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}">'
+27 -17
View File
@@ -102,25 +102,35 @@ regroupement à chaque appel.
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, puis la conséquence
de SON choix REMPLACE l'affichage de la situation (les boutons de
choix disparaissent avec elle) avant de passer au scénario suivant —
volontairement PAS le comportement du Quiz, où la question resterait
affichée à côté d'un encart de feedback séparé (retour utilisateur du
20/09/2026 : "ce n'est pas un quiz, la conséquence s'affiche à la
place de la situation précédente"). 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
un scénario est un ARBRE DE DÉCISION (voir `scenario_config.py` pour la
forme exacte — `nodes[0]` = situation initiale, chaque choix pointe
vers un autre nœud via `target_id`, un nœud sans choix est une fin de
branche), pas une simple question à une seule conséquence. Aucune
notion de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 :
"il n'y a pas de notion vrai/faux, l'utilisateur observe les
conséquences") : l'apprenant choisit une option, le texte du nœud visé
REMPLACE l'affichage du nœud précédent (les boutons de choix
disparaissent avec lui), et ainsi de suite jusqu'à une fin de branche
— volontairement PAS le comportement du Quiz, où la question resterait
affichée à côté d'un encart de feedback séparé. Une fois une fin de
branche atteinte, passage au scénario (arbre) 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.
`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que de dupliquer ces
règles. L'arbre complet (tous les nœuds/choix de tous les scénarios)
est 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 : toute la navigation dans
l'arbre se fait côté client. Comme l'Association/Memory/Mots mêlés
(mais contrairement au Quiz), le dernier scénario reste affiché une
fois une fin de branche atteinte : seul le bouton "Recommencer"
(`.docMinigameRestartBar`) apparaît. L'arbre lui-même se construit
dans une MODALE dédiée depuis le panneau Propriétés (voir
`forgeDocOpenScenarioTreeModal`, static/document/js/document-editor.js)
— trop de structure (nœuds + choix + destinations) pour la colonne
étroite du panneau Propriétés, contrairement aux autres mini-jeux.
- **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