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
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:
co-authored by
Claude Sonnet 5
parent
c04a81cfec
commit
a529857379
@@ -66,7 +66,6 @@ from .labels.quiz_config import (
|
|||||||
from .labels.scenario_config import (
|
from .labels.scenario_config import (
|
||||||
DEFAULT_SCENARIO_CONFIG,
|
DEFAULT_SCENARIO_CONFIG,
|
||||||
MAX_SCENARIO_CHOICES,
|
MAX_SCENARIO_CHOICES,
|
||||||
MIN_SCENARIO_CHOICES,
|
|
||||||
sanitize_scenario_config,
|
sanitize_scenario_config,
|
||||||
)
|
)
|
||||||
from .rendering.render_document_element import render_document, render_document_element
|
from .rendering.render_document_element import render_document, render_document_element
|
||||||
@@ -90,7 +89,6 @@ __all__ = [
|
|||||||
"MIN_CARDS",
|
"MIN_CARDS",
|
||||||
"MIN_CHOICES",
|
"MIN_CHOICES",
|
||||||
"MIN_PAIRS",
|
"MIN_PAIRS",
|
||||||
"MIN_SCENARIO_CHOICES",
|
|
||||||
"MIN_TIMER_SECONDS",
|
"MIN_TIMER_SECONDS",
|
||||||
"MIN_WORDS",
|
"MIN_WORDS",
|
||||||
"MINIGAME_KINDS",
|
"MINIGAME_KINDS",
|
||||||
|
|||||||
@@ -183,21 +183,41 @@ liste finale est tronquée à `MAX_WORDS`.
|
|||||||
## `scenario_config.py` — modèle de données du mini-jeu Scénario
|
## `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,
|
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
|
choisit une option, puis découvre la conséquence de SON choix — cette
|
||||||
choix ainsi que s'il s'agissait du bon choix (voir
|
conséquence peut elle-même mener à de nouveaux choix, et ainsi de suite
|
||||||
`document_engine/rendering/render_document_element.py`::
|
(arbre de décision, voir `document_engine/rendering/
|
||||||
`_render_scenario_player`). Le créateur peut définir plusieurs scénarios,
|
render_document_element.py`::`_render_scenario_player`). Aucune notion
|
||||||
joués les uns après les autres dans l'ORDRE d'écriture (jamais mélangés,
|
de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 : "il n'y a
|
||||||
contrairement à Association/Memory/Mots mêlés). Même convention que
|
pas de notion vrai/faux, l'utilisateur observe les conséquences") —
|
||||||
`quiz_config.py`.
|
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`
|
**Structure d'un scénario** : `{"title": str, "nodes": [...]}`.
|
||||||
Bornes de validation (`2`/`4` choix par scénario). Nommées explicitement
|
`nodes[0]` est TOUJOURS la situation initiale (racine de l'arbre, jamais
|
||||||
"SCENARIO" (pas juste `MIN_CHOICES`/`MAX_CHOICES`) pour éviter toute
|
supprimable depuis le panneau Propriétés — voir
|
||||||
collision avec les constantes de même nom de `quiz_config.py`, qui
|
`forgeDocRenderScenarioNodeHtml`, static/document/js/document-editor.js).
|
||||||
bornent un concept différent (le nombre de réponses d'une question de
|
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
|
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.
|
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]`
|
### `DEFAULT_SCENARIO_CONFIG: dict[str, Any]`
|
||||||
`{"theme_color": "#ff5f2e", "scenarios": []}`.
|
`{"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
|
jamais ne lève, renvoie toujours un dict COMPLET fusionné sur
|
||||||
`DEFAULT_SCENARIO_CONFIG`. Chaque scénario de `raw_config["scenarios"]`
|
`DEFAULT_SCENARIO_CONFIG`. Chaque scénario de `raw_config["scenarios"]`
|
||||||
est validé indépendamment (voir `_sanitize_scenario`/
|
est validé indépendamment (voir `_sanitize_scenario`/
|
||||||
`_sanitize_scenario_choice`, privées) : la `situation` doit être non
|
`_sanitize_scenario_node`/`_sanitize_scenario_choice`, privées) : le
|
||||||
vide une fois `.strip()`-ée, et il doit rester au moins
|
`title` doit être non vide, chaque nœud doit avoir un `id` (chaîne non
|
||||||
`MIN_SCENARIO_CHOICES` choix valides (texte non vide) une fois la liste
|
vide) et un `text` non vide une fois `.strip()`-é (sinon le nœud entier
|
||||||
tronquée à `MAX_SCENARIO_CHOICES` — sinon le scénario entier est
|
est supprimé), et ses choix sont tronqués à `MAX_SCENARIO_CHOICES`. Le
|
||||||
silencieusement supprimé de la liste. La `consequence` d'un choix, elle,
|
scénario entier est supprimé s'il ne reste plus aucun nœud valide après
|
||||||
peut rester vide (un créateur peut vouloir la renseigner plus tard sans
|
nettoyage (il faut au moins la situation initiale). Une fois l'ensemble
|
||||||
que ça invalide le choix). `correct_index` retombe sur `0` s'il est hors
|
des ids valides connu, tout `target_id` qui ne pointe plus vers un nœud
|
||||||
bornes ou absent.
|
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`).
|
- **Retour** : dict complet (mêmes clés que `DEFAULT_SCENARIO_CONFIG`).
|
||||||
- **Exceptions** : aucune.
|
- **Exceptions** : aucune.
|
||||||
|
|||||||
@@ -1,22 +1,30 @@
|
|||||||
"""Modèle de données du mini-jeu Scénario (voir docs/plan/PLAN.md §3.2) —
|
"""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,
|
l'apprenant lit une situation initiale, choisit une option, et découvre
|
||||||
puis découvre la conséquence de SON choix ainsi que s'il s'agissait du
|
la conséquence de SON choix ; cette conséquence peut elle-même mener à
|
||||||
bon choix (voir document_engine/rendering/render_document_element.py::
|
de nouveaux choix, et ainsi de suite (arbre de décision, voir
|
||||||
_render_scenario_player). Le créateur peut définir plusieurs scénarios,
|
document_engine/rendering/render_document_element.py::
|
||||||
joués les uns après les autres dans l'ordre d'écriture. Même convention
|
_render_scenario_player). Aucune notion de bonne/mauvaise réponse
|
||||||
resolve_X/sanitize_X que quiz_config.py/association_config.py/
|
(retour utilisateur du 20/09/2026 : "il n'y a pas de notion vrai/faux,
|
||||||
memory_config.py/mots_config.py (aucun import croisé) : sanitize_scenario_
|
l'utilisateur observe les conséquences") — l'apprenant explore
|
||||||
config est pure, ne lève jamais, et renvoie toujours un dict complet."""
|
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
|
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
|
MAX_SCENARIO_CHOICES = 4
|
||||||
|
|
||||||
DEFAULT_SCENARIO_CONFIG: dict[str, Any] = {
|
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
|
"""None si le choix est invalide (texte vide) — filtrée par
|
||||||
_sanitize_scenario plutôt que de faire échouer tout le scénario,
|
_sanitize_scenario_node plutôt que de faire échouer tout le nœud,
|
||||||
même convention que quiz_config.py::_sanitize_question. La
|
même convention que quiz_config.py::_sanitize_question. `target_id`
|
||||||
conséquence, elle, peut rester vide (un créateur peut vouloir la
|
est vérifié/nettoyé une seconde fois par _sanitize_scenario (une fois
|
||||||
renseigner plus tard sans que ça invalide le choix)."""
|
l'ensemble des ids valides du scénario connu), jamais ici."""
|
||||||
if not isinstance(raw, dict):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
text = str(raw.get("text", "")).strip()
|
text = str(raw.get("text", "")).strip()
|
||||||
if not text:
|
if not text:
|
||||||
return None
|
return None
|
||||||
consequence = str(raw.get("consequence", "")).strip()
|
target_id = raw.get("target_id")
|
||||||
return {"text": text, "consequence": consequence}
|
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:
|
def _sanitize_scenario(raw: Any) -> dict[str, Any] | None:
|
||||||
"""None si le scénario est invalide (situation vide, ou moins de
|
"""None si le scénario est invalide (titre vide, ou aucun nœud
|
||||||
MIN_SCENARIO_CHOICES choix non vides) — même convention que
|
valide restant après nettoyage — il faut au moins la situation
|
||||||
quiz_config.py::_sanitize_question."""
|
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):
|
if not isinstance(raw, dict):
|
||||||
return None
|
return None
|
||||||
situation = str(raw.get("situation", "")).strip()
|
title = str(raw.get("title", "")).strip()
|
||||||
if not situation:
|
if not title:
|
||||||
return None
|
return None
|
||||||
raw_choices = raw.get("choices")
|
raw_nodes = raw.get("nodes")
|
||||||
if not isinstance(raw_choices, list):
|
if not isinstance(raw_nodes, list):
|
||||||
return None
|
return None
|
||||||
choices = [c for c in (_sanitize_scenario_choice(item) for item in raw_choices) if c is not None]
|
nodes = []
|
||||||
choices = choices[:MAX_SCENARIO_CHOICES]
|
seen_ids: set[str] = set()
|
||||||
if len(choices) < MIN_SCENARIO_CHOICES:
|
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
|
return None
|
||||||
correct_index = raw.get("correct_index")
|
valid_ids = {n["id"] for n in nodes}
|
||||||
if not isinstance(correct_index, int) or isinstance(correct_index, bool) or not (0 <= correct_index < len(choices)):
|
for node in nodes:
|
||||||
correct_index = 0
|
node["choices"] = [
|
||||||
return {"situation": situation, "choices": choices, "correct_index": correct_index}
|
{**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]:
|
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:
|
def _render_scenario_player(config: dict[str, Any]) -> str:
|
||||||
"""Mise en situation RÉELLEMENT interactive — affichée uniquement en
|
"""Mise en situation RÉELLEMENT interactive — affichée uniquement en
|
||||||
Mode Aperçu, même principe que les autres mini-jeux : aucun
|
Mode Aperçu, même principe que les autres mini-jeux : aucun
|
||||||
aller-retour serveur, tout le déroulé (lecture de la situation, choix,
|
aller-retour serveur, tout le déroulé (navigation dans l'arbre de
|
||||||
conséquence révélée, scénario suivant) est géré par
|
décision, scénario suivant) est géré par static/document/js/
|
||||||
static/document/js/document-editor.js à partir du JSON embarqué. Les
|
document-editor.js à partir du JSON embarqué (voir scenario_config.py
|
||||||
scénarios gardent l'ORDRE d'écriture du créateur (contrairement à
|
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
|
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
|
en situation séquentielles, pas des éléments à faire correspondre ou
|
||||||
retrouver — un mélange n'aurait ici aucun sens pédagogique. Un seul
|
retrouver — un mélange n'aurait ici aucun sens pédagogique. Un seul
|
||||||
bloc de texte (.docScenarioSituation) sert successivement à afficher
|
bloc de texte (.docScenarioSituation) sert successivement à afficher
|
||||||
la situation PUIS, une fois un choix fait, la conséquence à sa place
|
le texte de chaque nœud visité : une fois un choix fait, le texte du
|
||||||
(les boutons de choix disparaissent aussi) — volontairement PAS le
|
nœud suivant REMPLACE le précédent (les boutons de choix disparaissent
|
||||||
comportement du Quiz, où la question reste affichée à côté d'un
|
avec lui) — volontairement PAS le comportement du Quiz, où la
|
||||||
encart de feedback séparé (retour utilisateur du 20/09/2026 : "ce
|
question resterait affichée à côté d'un encart de feedback séparé.
|
||||||
n'est pas un quiz, la conséquence s'affiche à la place de la
|
Aucune notion de bonne/mauvaise réponse ici (retour utilisateur du
|
||||||
situation précédente"). Réutilise les classes visuelles du Quiz
|
20/09/2026 : "il n'y a pas de notion vrai/faux, l'utilisateur observe
|
||||||
(.docQuizOptions/.docQuizFeedback/.docQuizNextBar/
|
les conséquences") — un nœud sans choix est simplement une fin de
|
||||||
.docQuizQuestionText) plutôt que de dupliquer ces règles, même esprit
|
branche. Réutilise les classes visuelles du Quiz
|
||||||
que .docAssocCard/.docMinigameRestartBar."""
|
(.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)
|
config_json = html_lib.escape(json.dumps({"scenarios": config["scenarios"]}), quote=True)
|
||||||
return (
|
return (
|
||||||
f'<div class="docScenarioPlayer" data-scenario-config="{config_json}">'
|
f'<div class="docScenarioPlayer" data-scenario-config="{config_json}">'
|
||||||
|
|||||||
@@ -102,25 +102,35 @@ regroupement à chaque appel.
|
|||||||
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
|
scénarios) — sanitizée (`sanitize_scenario_config`) avant lecture. Si
|
||||||
au moins un scénario existe, s'y ajoute (fonction privée
|
au moins un scénario existe, s'y ajoute (fonction privée
|
||||||
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
|
`_render_scenario_player`) la mise en situation RÉELLE et interactive :
|
||||||
l'apprenant lit la situation, choisit une option, puis la conséquence
|
un scénario est un ARBRE DE DÉCISION (voir `scenario_config.py` pour la
|
||||||
de SON choix REMPLACE l'affichage de la situation (les boutons de
|
forme exacte — `nodes[0]` = situation initiale, chaque choix pointe
|
||||||
choix disparaissent avec elle) avant de passer au scénario suivant —
|
vers un autre nœud via `target_id`, un nœud sans choix est une fin de
|
||||||
volontairement PAS le comportement du Quiz, où la question resterait
|
branche), pas une simple question à une seule conséquence. Aucune
|
||||||
affichée à côté d'un encart de feedback séparé (retour utilisateur du
|
notion de bonne/mauvaise réponse (retour utilisateur du 20/09/2026 :
|
||||||
20/09/2026 : "ce n'est pas un quiz, la conséquence s'affiche à la
|
"il n'y a pas de notion vrai/faux, l'utilisateur observe les
|
||||||
place de la situation précédente"). Les scénarios gardent l'ORDRE
|
conséquences") : l'apprenant choisit une option, le texte du nœud visé
|
||||||
d'écriture du créateur (jamais mélangés, contrairement à
|
REMPLACE l'affichage du nœud précédent (les boutons de choix
|
||||||
Association/Memory/Mots mêlés — ce sont des mises en situation
|
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).
|
séquentielles, pas des éléments à faire correspondre/retrouver).
|
||||||
Réutilise les classes visuelles du Quiz (`.docQuizOptions`/
|
Réutilise les classes visuelles du Quiz (`.docQuizOptions`/
|
||||||
`.docQuizFeedback`/`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que
|
`.docQuizNextBar`/`.docQuizQuestionText`) plutôt que de dupliquer ces
|
||||||
de dupliquer ces règles. Embarqué en
|
règles. L'arbre complet (tous les nœuds/choix de tous les scénarios)
|
||||||
JSON dans un attribut `data-scenario-config`, échappé pour l'HTML —
|
est embarqué en JSON dans un attribut `data-scenario-config`, échappé
|
||||||
même principe que les autres mini-jeux, aucun aller-retour serveur
|
pour l'HTML — même principe que les autres mini-jeux, aucun
|
||||||
pendant qu'on joue. Comme l'Association/Memory/Mots mêlés (mais
|
aller-retour serveur pendant qu'on joue : toute la navigation dans
|
||||||
contrairement au Quiz), le dernier scénario reste affiché une fois
|
l'arbre se fait côté client. Comme l'Association/Memory/Mots mêlés
|
||||||
répondu : seul le bouton "Recommencer" (`.docMinigameRestartBar`)
|
(mais contrairement au Quiz), le dernier scénario reste affiché une
|
||||||
apparaît.
|
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é
|
- **Autres mini-jeux** (`zones`) : carte placeholder portant le libellé
|
||||||
du type (voir `document_engine/labels/element_kind_labels.py`) —
|
du type (voir `document_engine/labels/element_kind_labels.py`) —
|
||||||
emplacement réservé, formulaire de contenu dédié hors périmètre de
|
emplacement réservé, formulaire de contenu dédié hors périmètre de
|
||||||
|
|||||||
@@ -1398,6 +1398,77 @@ img.docImage {
|
|||||||
padding: 40px;
|
padding: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Modale générique (utilisée aujourd'hui par l'éditeur d'arbre du
|
||||||
|
mini-jeu Scénario, voir forgeDocOpenScenarioTreeModal) — overlay +
|
||||||
|
boîte de dialogue centrée, indépendante de tout framework externe
|
||||||
|
(aucune dépendance partagée avec game_engine, voir CLAUDE.md). ---- */
|
||||||
|
.docModalBackdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(11 14 20 / 65%);
|
||||||
|
z-index: 200;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalBackdrop.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalDialog {
|
||||||
|
background: var(--doc-bg-2);
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: min(640px, 100%);
|
||||||
|
max-height: min(80vh, 720px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid var(--doc-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalTitle {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--doc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalCloseBtn {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--doc-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalCloseBtn:hover {
|
||||||
|
color: var(--doc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docModalBody {
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scénario : panneau Propriétés — une carte par scénario (titre +
|
||||||
|
nombre de nœuds + bouton d'ouverture de la modale), l'édition de
|
||||||
|
l'arbre lui-même se fait entièrement dans la modale (voir
|
||||||
|
forgeDocRenderScenarioTreeModal). ---- */
|
||||||
|
.docScenarioCard .docBtnSecondary {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
|
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
|
||||||
@media (width <= 900px) {
|
@media (width <= 900px) {
|
||||||
.docCrumbDim {
|
.docCrumbDim {
|
||||||
|
|||||||
@@ -1138,15 +1138,22 @@ function forgeDocInitMotsPlayers() {
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------------
|
/* ---------------------------------------------------------------------
|
||||||
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
|
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
|
||||||
* l'apprenant lit une situation, choisit une option, découvre la
|
* un scénario est un ARBRE DE DÉCISION (voir document_engine/labels/
|
||||||
* conséquence de SON choix ainsi que si c'était le bon, puis passe au
|
* scenario_config.py pour la forme exacte), pas une simple question à
|
||||||
* scénario suivant. Les scénarios se jouent dans l'ORDRE d'écriture
|
* une seule conséquence. L'apprenant lit le texte du nœud courant
|
||||||
* (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés) :
|
* (situation initiale, puis conséquences), choisit une option, et le
|
||||||
* ce sont des mises en situation séquentielles. Comme ces trois
|
* texte du nœud visé REMPLACE l'affichage du nœud précédent (les
|
||||||
* mini-jeux, le dernier scénario reste affiché une fois répondu : seul
|
* boutons de choix disparaissent avec lui) — et ainsi de suite jusqu'à
|
||||||
* le bouton Recommencer (.docMinigameRestartBar) apparaît, jamais un
|
* une fin de branche (nœud sans choix). Aucune notion de bonne/mauvaise
|
||||||
* écran de résultat séparé (ça reste le comportement du Quiz). État
|
* réponse (retour utilisateur du 20/09/2026 : "il n'y a pas de notion
|
||||||
* tenu en mémoire, jamais persisté.
|
* vrai/faux, l'utilisateur observe les conséquences"). Les scénarios
|
||||||
|
* (plusieurs arbres indépendants) se jouent dans l'ORDRE d'écriture
|
||||||
|
* (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés).
|
||||||
|
* Comme ces trois mini-jeux, le dernier arbre reste affiché une fois une
|
||||||
|
* fin de branche atteinte : seul le bouton Recommencer
|
||||||
|
* (.docMinigameRestartBar) apparaît, jamais un écran de résultat séparé
|
||||||
|
* (ça reste le comportement du Quiz). État tenu en mémoire, jamais
|
||||||
|
* persisté.
|
||||||
* ------------------------------------------------------------------- */
|
* ------------------------------------------------------------------- */
|
||||||
|
|
||||||
function forgeDocScenarioPlayerData(playerEl) {
|
function forgeDocScenarioPlayerData(playerEl) {
|
||||||
@@ -1158,54 +1165,12 @@ function forgeDocScenarioPlayerData(playerEl) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocRenderScenarioPlayerScenario(playerEl, state) {
|
function forgeDocScenarioFindNode(tree, nodeId) {
|
||||||
state.answered = false;
|
return tree.nodes.find((n) => n.id === nodeId) || tree.nodes[0];
|
||||||
const item = state.scenarios[state.current];
|
|
||||||
const situationEl = playerEl.querySelector('.docScenarioSituation');
|
|
||||||
const choicesEl = playerEl.querySelector('.docScenarioChoices');
|
|
||||||
const progEl = playerEl.querySelector('.docScenarioProg');
|
|
||||||
const nextBtn = playerEl.querySelector('.docScenarioNextBtn');
|
|
||||||
|
|
||||||
// Repart d'un bloc "situation" neutre (jamais un résidu des classes
|
|
||||||
// is-visible/is-ok/is-ko posées par une réponse précédente, voir
|
|
||||||
// forgeDocScenarioPlayerAnswer ci-dessous).
|
|
||||||
situationEl.className = 'docScenarioSituation docQuizQuestionText';
|
|
||||||
situationEl.textContent = item.situation;
|
|
||||||
choicesEl.innerHTML = '';
|
|
||||||
nextBtn.classList.remove('is-visible');
|
|
||||||
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
|
|
||||||
item.choices.forEach((choice, idx) => {
|
|
||||||
const btn = document.createElement('button');
|
|
||||||
btn.type = 'button';
|
|
||||||
btn.className = 'docQuizOption docScenarioChoice';
|
|
||||||
btn.textContent = choice.text;
|
|
||||||
btn.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
forgeDocScenarioPlayerAnswer(playerEl, state, idx);
|
|
||||||
});
|
|
||||||
choicesEl.appendChild(btn);
|
|
||||||
});
|
|
||||||
progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocScenarioPlayerAnswer(playerEl, state, idx) {
|
function forgeDocScenarioReachEnd(playerEl, state) {
|
||||||
if (state.answered) return;
|
|
||||||
state.answered = true;
|
|
||||||
const item = state.scenarios[state.current];
|
|
||||||
const situationEl = playerEl.querySelector('.docScenarioSituation');
|
|
||||||
const choicesEl = playerEl.querySelector('.docScenarioChoices');
|
|
||||||
const isLast = state.current === state.scenarios.length - 1;
|
const isLast = state.current === state.scenarios.length - 1;
|
||||||
const isCorrect = idx === item.correct_index;
|
|
||||||
const consequenceText = item.choices[idx].consequence || 'Aucune conséquence renseignée pour ce choix.';
|
|
||||||
|
|
||||||
// Ce n'est pas un Quiz : la conséquence REMPLACE la situation (le récit
|
|
||||||
// avance), et les boutons de choix disparaissent avec elle — jamais un
|
|
||||||
// encart de feedback séparé pendant que la situation resterait affichée
|
|
||||||
// (retour utilisateur du 20/09/2026).
|
|
||||||
situationEl.className = `docScenarioSituation docQuizFeedback is-visible ${isCorrect ? 'is-ok' : 'is-ko'}`;
|
|
||||||
situationEl.textContent = consequenceText;
|
|
||||||
choicesEl.innerHTML = '';
|
|
||||||
|
|
||||||
if (isLast) {
|
if (isLast) {
|
||||||
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
||||||
} else {
|
} else {
|
||||||
@@ -1213,24 +1178,71 @@ function forgeDocScenarioPlayerAnswer(playerEl, state, idx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocScenarioPlayerChoose(playerEl, state, choice) {
|
||||||
|
const tree = state.scenarios[state.current];
|
||||||
|
if (choice.target_id && tree.nodes.some((n) => n.id === choice.target_id)) {
|
||||||
|
state.currentNodeId = choice.target_id;
|
||||||
|
forgeDocRenderScenarioNode(playerEl, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Choix sans destination valide (fin de branche déclarée sur CE choix,
|
||||||
|
// ou référence cassée dégradée côté serveur — voir
|
||||||
|
// sanitize_scenario_config) : le texte du nœud courant reste affiché
|
||||||
|
// tel quel, seuls les boutons de choix disparaissent.
|
||||||
|
playerEl.querySelector('.docScenarioChoices').innerHTML = '';
|
||||||
|
forgeDocScenarioReachEnd(playerEl, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderScenarioNode(playerEl, state) {
|
||||||
|
const tree = state.scenarios[state.current];
|
||||||
|
const node = forgeDocScenarioFindNode(tree, state.currentNodeId);
|
||||||
|
const situationEl = playerEl.querySelector('.docScenarioSituation');
|
||||||
|
const choicesEl = playerEl.querySelector('.docScenarioChoices');
|
||||||
|
const progEl = playerEl.querySelector('.docScenarioProg');
|
||||||
|
|
||||||
|
situationEl.textContent = node.text;
|
||||||
|
choicesEl.innerHTML = '';
|
||||||
|
playerEl.querySelector('.docScenarioNextBtn').classList.remove('is-visible');
|
||||||
|
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
|
||||||
|
progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length} — ${tree.title}`;
|
||||||
|
|
||||||
|
if (!node.choices.length) {
|
||||||
|
forgeDocScenarioReachEnd(playerEl, state);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.choices.forEach((choice) => {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'docQuizOption docScenarioChoice';
|
||||||
|
btn.textContent = choice.text;
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
forgeDocScenarioPlayerChoose(playerEl, state, choice);
|
||||||
|
});
|
||||||
|
choicesEl.appendChild(btn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocScenarioPlayerNext(playerEl, state) {
|
function forgeDocScenarioPlayerNext(playerEl, state) {
|
||||||
state.current += 1;
|
state.current += 1;
|
||||||
if (state.current < state.scenarios.length) {
|
if (state.current < state.scenarios.length) {
|
||||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
state.currentNodeId = state.scenarios[state.current].nodes[0].id;
|
||||||
|
forgeDocRenderScenarioNode(playerEl, state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocScenarioRestart(playerEl, state) {
|
function forgeDocScenarioRestart(playerEl, state) {
|
||||||
state.current = 0;
|
state.current = 0;
|
||||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
state.currentNodeId = state.scenarios[0].nodes[0].id;
|
||||||
|
forgeDocRenderScenarioNode(playerEl, state);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocInitScenarioPlayers() {
|
function forgeDocInitScenarioPlayers() {
|
||||||
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
|
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
|
||||||
const scenarios = forgeDocScenarioPlayerData(playerEl);
|
const scenarios = forgeDocScenarioPlayerData(playerEl);
|
||||||
if (!scenarios.length) return;
|
if (!scenarios.length || !scenarios[0].nodes || !scenarios[0].nodes.length) return;
|
||||||
const state = { current: 0, answered: false, scenarios };
|
const state = { current: 0, scenarios, currentNodeId: scenarios[0].nodes[0].id };
|
||||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
forgeDocRenderScenarioNode(playerEl, state);
|
||||||
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
|
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
forgeDocScenarioPlayerNext(playerEl, state);
|
forgeDocScenarioPlayerNext(playerEl, state);
|
||||||
@@ -1844,75 +1856,51 @@ function forgeDocRenderMotsProps(panel, el) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
* Scénario — panneau Propriétés : une carte par scénario (titre + nombre
|
||||||
|
* de nœuds), l'arbre lui-même se construit dans une MODALE dédiée (trop
|
||||||
|
* de structure — nœuds + choix + destinations — pour la colonne étroite
|
||||||
|
* du panneau Propriétés, contrairement aux autres mini-jeux). Voir
|
||||||
|
* forgeDocOpenScenarioTreeModal ci-dessous.
|
||||||
|
* ------------------------------------------------------------------- */
|
||||||
|
|
||||||
function forgeDocScenarioNewScenario() {
|
function forgeDocScenarioNewScenario() {
|
||||||
// Champs pré-remplis (jamais vides) : sanitize_scenario_config
|
// Titre et situation initiale pré-remplis (jamais vides) :
|
||||||
// (routes/document/document_element_update.py) rejette silencieusement
|
// sanitize_scenario_config (routes/document/document_element_update.py)
|
||||||
// tout scénario dont la situation est vide, ou tout choix dont le
|
// rejette silencieusement tout scénario dont le titre est vide, ou
|
||||||
// texte est vide — même leçon que forgeDocAssociationNewPair/
|
// tout nœud dont le texte est vide — même leçon que
|
||||||
// forgeDocMemoryNewCard/l'ajout de mot des Mots mêlés.
|
// forgeDocAssociationNewPair/forgeDocMemoryNewCard/l'ajout de mot des
|
||||||
|
// Mots mêlés.
|
||||||
return {
|
return {
|
||||||
situation: 'Nouvelle situation',
|
title: 'Nouveau scénario',
|
||||||
choices: [
|
nodes: [{ id: 'n1', text: 'Nouvelle situation', choices: [] }],
|
||||||
{ text: 'Choix 1', consequence: 'Conséquence du choix 1' },
|
|
||||||
{ text: 'Choix 2', consequence: 'Conséquence du choix 2' },
|
|
||||||
],
|
|
||||||
correct_index: 0,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocRenderScenarioChoiceHtml(choice, sIndex, cIndex, correctIndex) {
|
function forgeDocScenarioNewNode(id) {
|
||||||
return `
|
return { id, text: 'Nouveau nœud', choices: [] };
|
||||||
<div class="docScenarioChoiceBlock">
|
|
||||||
<div class="docQuizChoiceRow">
|
|
||||||
<input type="radio"
|
|
||||||
name="docScenarioCorrect${sIndex}"
|
|
||||||
class="docQuizCorrectRadio docScenarioCorrectRadio"
|
|
||||||
data-scenario-index="${sIndex}"
|
|
||||||
data-choice-index="${cIndex}"
|
|
||||||
${cIndex === correctIndex ? 'checked' : ''}>
|
|
||||||
<input type="text"
|
|
||||||
class="docQuizChoiceInput docScenarioChoiceInput"
|
|
||||||
value="${forgeDocEscapeHtml(choice.text)}"
|
|
||||||
placeholder="Choix ${cIndex + 1}"
|
|
||||||
data-scenario-index="${sIndex}"
|
|
||||||
data-choice-index="${cIndex}">
|
|
||||||
</div>
|
|
||||||
<textarea class="docAssocPairInput docScenarioConsequenceInput"
|
|
||||||
rows="2"
|
|
||||||
placeholder="Conséquence de ce choix"
|
|
||||||
data-scenario-index="${sIndex}"
|
|
||||||
data-choice-index="${cIndex}">${forgeDocEscapeHtml(choice.consequence)}</textarea>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocRenderScenarioHtml(scenario, sIndex) {
|
function forgeDocScenarioGenerateNodeId(nodes) {
|
||||||
const choicesHtml = scenario.choices
|
// Un id JAMAIS réutilisé, même après suppression d'un nœud (sinon un
|
||||||
.map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index))
|
// choix existant pointant vers un ancien id supprimé pourrait se
|
||||||
.join('');
|
// retrouver à pointer vers le NOUVEAU nœud qui hérite du même id, un
|
||||||
|
// lien créé par erreur plutôt qu'une vraie fin de branche).
|
||||||
|
const used = nodes.map((n) => Number((n.id.match(/^n(\d+)$/) || [])[1])).filter((n) => !Number.isNaN(n));
|
||||||
|
const next = (used.length ? Math.max(...used) : 0) + 1;
|
||||||
|
return `n${next}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderScenarioCardHtml(scenario, sIndex) {
|
||||||
|
const nodeCount = scenario.nodes.length;
|
||||||
return `
|
return `
|
||||||
<div class="docQuizQuestion docScenarioCard" data-scenario-index="${sIndex}">
|
<div class="docQuizQuestion docScenarioCard" data-scenario-index="${sIndex}">
|
||||||
<div class="docQuizQuestionHeader">
|
<div class="docQuizQuestionHeader">
|
||||||
<span class="docFieldLabel">Scénario ${sIndex + 1}</span>
|
<span class="docFieldLabel">${forgeDocEscapeHtml(scenario.title)}</span>
|
||||||
<button type="button" class="docQuizRemoveBtn docScenarioRemoveBtn" data-scenario-index="${sIndex}" title="Supprimer ce scénario">🗑</button>
|
<button type="button" class="docQuizRemoveBtn docScenarioRemoveBtn" data-scenario-index="${sIndex}" title="Supprimer ce scénario">🗑</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="docField">
|
<div class="docFieldRow"><span class="docFieldLabel">${nodeCount} nœud${nodeCount > 1 ? 's' : ''}</span></div>
|
||||||
<textarea class="docScenarioSituationInput"
|
<button type="button" class="docBtnSecondary docScenarioEditTreeBtn" data-scenario-index="${sIndex}">✎ Modifier l'arbre</button>
|
||||||
rows="3"
|
|
||||||
placeholder="Situation initiale"
|
|
||||||
data-scenario-index="${sIndex}">${forgeDocEscapeHtml(scenario.situation)}</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="docFieldRow">
|
|
||||||
<label>Nombre de choix</label>
|
|
||||||
<input type="number"
|
|
||||||
class="docScenarioChoiceCountInput"
|
|
||||||
min="2"
|
|
||||||
max="4"
|
|
||||||
value="${scenario.choices.length}"
|
|
||||||
data-scenario-index="${sIndex}"
|
|
||||||
style="width:60px;">
|
|
||||||
</div>
|
|
||||||
<div class="docQuizChoicesList">${choicesHtml}</div>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -1936,7 +1924,7 @@ function forgeDocRenderScenarioProps(panel, el) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="docSectionLabel docQuizQuestionsLabel">Scénarios</div>
|
<div class="docSectionLabel docQuizQuestionsLabel">Scénarios</div>
|
||||||
<div id="docScenarioList">${scenarios.length ? scenarios.map(forgeDocRenderScenarioHtml).join('') : '<div class="docEmptyState">Aucun scénario — ajoute le premier ci-dessous.</div>'}</div>
|
<div id="docScenarioList">${scenarios.length ? scenarios.map(forgeDocRenderScenarioCardHtml).join('') : '<div class="docEmptyState">Aucun scénario — ajoute le premier ci-dessous.</div>'}</div>
|
||||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioAdd">+ Ajouter un scénario</button>
|
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioAdd">+ Ajouter un scénario</button>
|
||||||
${forgeDocDeleteButtonHtml()}
|
${forgeDocDeleteButtonHtml()}
|
||||||
`;
|
`;
|
||||||
@@ -1956,65 +1944,195 @@ function forgeDocRenderScenarioProps(panel, el) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.querySelectorAll('.docScenarioSituationInput').forEach((input) => {
|
panel.querySelectorAll('.docScenarioEditTreeBtn').forEach((btn) => {
|
||||||
input.addEventListener('change', (e) => {
|
btn.addEventListener('click', () => {
|
||||||
const sIndex = Number(input.dataset.scenarioIndex);
|
const sIndex = Number(btn.dataset.scenarioIndex);
|
||||||
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, situation: e.target.value } : s)) });
|
forgeDocOpenScenarioTreeModal(el.id, sIndex);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------
|
||||||
|
* Scénario — modale de construction de l'arbre : une carte par nœud
|
||||||
|
* (texte + liste de choix), chaque choix a un menu déroulant "mène à"
|
||||||
|
* listant les autres nœuds du même scénario (ou "— Fin de branche —").
|
||||||
|
* La situation initiale (nodes[0]) n'est jamais supprimable depuis ici.
|
||||||
|
* Persistance identique aux autres panneaux : chaque modification
|
||||||
|
* envoie l'état COMPLET du scénario au serveur (voir patch ci-dessous),
|
||||||
|
* jamais un état simulé côté client qui pourrait diverger de ce que
|
||||||
|
* sanitize_scenario_config a réellement accepté.
|
||||||
|
* ------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function forgeDocRenderScenarioNodeHtml(node, nIndex, allNodes, isRoot) {
|
||||||
|
const choicesHtml = node.choices.map((choice, cIndex) => `
|
||||||
|
<div class="docScenarioChoiceBlock">
|
||||||
|
<div class="docQuizChoiceRow">
|
||||||
|
<input type="text"
|
||||||
|
class="docQuizChoiceInput docScenarioTreeChoiceInput"
|
||||||
|
value="${forgeDocEscapeHtml(choice.text)}"
|
||||||
|
placeholder="Texte du choix"
|
||||||
|
data-node-index="${nIndex}"
|
||||||
|
data-choice-index="${cIndex}">
|
||||||
|
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-node-index="${nIndex}" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div class="docFieldRow">
|
||||||
|
<label>Mène à</label>
|
||||||
|
<select class="docScenarioTreeTargetSelect" data-node-index="${nIndex}" data-choice-index="${cIndex}">
|
||||||
|
<option value="">— Fin de branche —</option>
|
||||||
|
${allNodes.filter((n) => n.id !== node.id).map((n) => `<option value="${n.id}" ${choice.target_id === n.id ? 'selected' : ''}>${forgeDocEscapeHtml(n.text.slice(0, 40) || n.id)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="docQuizQuestion docScenarioTreeNode" data-node-index="${nIndex}">
|
||||||
|
<div class="docQuizQuestionHeader">
|
||||||
|
<span class="docFieldLabel">${isRoot ? 'Situation initiale' : `Nœud ${nIndex + 1}`}</span>
|
||||||
|
${isRoot ? '' : `<button type="button" class="docQuizRemoveBtn docScenarioTreeNodeRemoveBtn" data-node-index="${nIndex}" title="Supprimer ce nœud">🗑</button>`}
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<textarea class="docScenarioTreeTextInput" rows="3" data-node-index="${nIndex}" placeholder="Texte (situation ou conséquence)">${forgeDocEscapeHtml(node.text)}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
|
||||||
|
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
|
||||||
|
<button type="button" class="docBtnSecondary docScenarioTreeAddChoiceBtn" data-node-index="${nIndex}" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderScenarioTreeModal(elementId, sIndex) {
|
||||||
|
const el = window.forgeDocState.elementsById[elementId];
|
||||||
|
const scenario = el && (el.attributes.scenarios || [])[sIndex];
|
||||||
|
if (!scenario) {
|
||||||
|
forgeDocCloseScenarioTreeModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = document.getElementById('docScenarioTreeModalBody');
|
||||||
|
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
|
||||||
|
|
||||||
|
function patch(partial) {
|
||||||
|
const scenarios = el.attributes.scenarios.map((s, i) => (i === sIndex ? { ...s, ...partial } : s));
|
||||||
|
forgeDocUpdateAttributes(elementId, { ...el.attributes, scenarios }).then(() => {
|
||||||
|
forgeDocRenderScenarioTreeModal(elementId, sIndex);
|
||||||
|
// Le panneau Propriétés affiche le nombre de nœuds par carte :
|
||||||
|
// le tenir synchronisé pendant que la modale reste ouverte.
|
||||||
|
if (window.forgeDocState.selectedId === elementId) {
|
||||||
|
forgeDocRenderProps(window.forgeDocState.elementsById[elementId]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
<div class="docField">
|
||||||
|
<label for="docScenarioTreeTitleInput">Nom du scénario</label>
|
||||||
|
<input type="text" id="docScenarioTreeTitleInput" value="${forgeDocEscapeHtml(scenario.title)}">
|
||||||
|
</div>
|
||||||
|
<div id="docScenarioTreeNodesList">${scenario.nodes.map((n, i) => forgeDocRenderScenarioNodeHtml(n, i, scenario.nodes, i === 0)).join('')}</div>
|
||||||
|
<button type="button" class="docBtnSecondary docQuizAddBtn docScenarioTreeAddNodeBtn">+ Ajouter un nœud</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => patch({ title: e.target.value }));
|
||||||
|
|
||||||
|
body.querySelector('.docScenarioTreeAddNodeBtn').addEventListener('click', () => {
|
||||||
|
const newId = forgeDocScenarioGenerateNodeId(scenario.nodes);
|
||||||
|
patch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId)] });
|
||||||
|
});
|
||||||
|
|
||||||
|
body.querySelectorAll('.docScenarioTreeNodeRemoveBtn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const nIndex = Number(btn.dataset.nodeIndex);
|
||||||
|
const removedId = scenario.nodes[nIndex].id;
|
||||||
|
// Un choix qui pointait vers ce nœud devient "fin de branche"
|
||||||
|
// (jamais une référence morte) — même filet de sécurité que
|
||||||
|
// sanitize_scenario_config côté serveur.
|
||||||
|
const nodes = scenario.nodes
|
||||||
|
.filter((_, i) => i !== nIndex)
|
||||||
|
.map((n) => ({
|
||||||
|
...n,
|
||||||
|
choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)),
|
||||||
|
}));
|
||||||
|
patch({ nodes });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.querySelectorAll('.docScenarioChoiceCountInput').forEach((input) => {
|
body.querySelectorAll('.docScenarioTreeTextInput').forEach((input) => {
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener('change', (e) => {
|
||||||
const sIndex = Number(input.dataset.scenarioIndex);
|
const nIndex = Number(input.dataset.nodeIndex);
|
||||||
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
|
patch({ nodes: scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n)) });
|
||||||
patch({
|
|
||||||
scenarios: scenarios.map((s, i) => {
|
|
||||||
if (i !== sIndex) return s;
|
|
||||||
const choices = s.choices.slice(0, count);
|
|
||||||
while (choices.length < count) {
|
|
||||||
choices.push({ text: `Choix ${choices.length + 1}`, consequence: '' });
|
|
||||||
}
|
|
||||||
return { ...s, choices, correct_index: s.correct_index < count ? s.correct_index : 0 };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.querySelectorAll('.docScenarioChoiceInput').forEach((input) => {
|
body.querySelectorAll('.docScenarioTreeAddChoiceBtn').forEach((btn) => {
|
||||||
input.addEventListener('change', (e) => {
|
btn.addEventListener('click', () => {
|
||||||
const sIndex = Number(input.dataset.scenarioIndex);
|
const nIndex = Number(btn.dataset.nodeIndex);
|
||||||
const cIndex = Number(input.dataset.choiceIndex);
|
|
||||||
patch({
|
patch({
|
||||||
scenarios: scenarios.map((s, i) => (
|
nodes: scenario.nodes.map((n, i) => (
|
||||||
i === sIndex
|
i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n
|
||||||
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
|
||||||
: s
|
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.querySelectorAll('.docScenarioConsequenceInput').forEach((input) => {
|
body.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
|
||||||
input.addEventListener('change', (e) => {
|
btn.addEventListener('click', () => {
|
||||||
const sIndex = Number(input.dataset.scenarioIndex);
|
const nIndex = Number(btn.dataset.nodeIndex);
|
||||||
const cIndex = Number(input.dataset.choiceIndex);
|
const cIndex = Number(btn.dataset.choiceIndex);
|
||||||
patch({
|
patch({
|
||||||
scenarios: scenarios.map((s, i) => (
|
nodes: scenario.nodes.map((n, i) => (
|
||||||
i === sIndex
|
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
|
||||||
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, consequence: e.target.value } : c)) }
|
|
||||||
: s
|
|
||||||
)),
|
)),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
panel.querySelectorAll('.docScenarioCorrectRadio').forEach((radio) => {
|
body.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => {
|
||||||
radio.addEventListener('change', () => {
|
input.addEventListener('change', (e) => {
|
||||||
const sIndex = Number(radio.dataset.scenarioIndex);
|
const nIndex = Number(input.dataset.nodeIndex);
|
||||||
const cIndex = Number(radio.dataset.choiceIndex);
|
const cIndex = Number(input.dataset.choiceIndex);
|
||||||
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, correct_index: cIndex } : s)) });
|
patch({
|
||||||
|
nodes: scenario.nodes.map((n, i) => (
|
||||||
|
i === nIndex
|
||||||
|
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
||||||
|
: n
|
||||||
|
)),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
body.querySelectorAll('.docScenarioTreeTargetSelect').forEach((select) => {
|
||||||
|
select.addEventListener('change', (e) => {
|
||||||
|
const nIndex = Number(select.dataset.nodeIndex);
|
||||||
|
const cIndex = Number(select.dataset.choiceIndex);
|
||||||
|
const targetId = e.target.value || null;
|
||||||
|
patch({
|
||||||
|
nodes: scenario.nodes.map((n, i) => (
|
||||||
|
i === nIndex
|
||||||
|
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: targetId } : c)) }
|
||||||
|
: n
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocOpenScenarioTreeModal(elementId, sIndex) {
|
||||||
|
document.getElementById('docScenarioTreeModal').classList.add('is-open');
|
||||||
|
forgeDocRenderScenarioTreeModal(elementId, sIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocCloseScenarioTreeModal() {
|
||||||
|
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function forgeDocBindScenarioTreeModal() {
|
||||||
|
document.getElementById('docScenarioTreeModalClose').addEventListener('click', forgeDocCloseScenarioTreeModal);
|
||||||
|
document.getElementById('docScenarioTreeModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal();
|
||||||
|
});
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape') forgeDocCloseScenarioTreeModal();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocRenderProps(el) {
|
function forgeDocRenderProps(el) {
|
||||||
@@ -2173,6 +2291,7 @@ function forgeDocInit() {
|
|||||||
forgeDocBindThemeToggle();
|
forgeDocBindThemeToggle();
|
||||||
forgeDocBindMobileNav();
|
forgeDocBindMobileNav();
|
||||||
forgeDocBindKeyboardShortcuts();
|
forgeDocBindKeyboardShortcuts();
|
||||||
|
forgeDocBindScenarioTreeModal();
|
||||||
forgeDocUpdateHistoryButtons();
|
forgeDocUpdateHistoryButtons();
|
||||||
|
|
||||||
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);
|
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);
|
||||||
|
|||||||
@@ -150,6 +150,18 @@
|
|||||||
<button type="button" data-target="right">Propriétés</button>
|
<button type="button" data-target="right">Propriétés</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="docModalBackdrop" id="docScenarioTreeModal">
|
||||||
|
<div class="docModalDialog">
|
||||||
|
<div class="docModalHeader">
|
||||||
|
<span class="docModalTitle" id="docScenarioTreeModalTitle">Arbre du scénario</span>
|
||||||
|
<button type="button"
|
||||||
|
class="docModalCloseBtn"
|
||||||
|
id="docScenarioTreeModalClose"
|
||||||
|
aria-label="Fermer">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="docModalBody" id="docScenarioTreeModalBody"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
window.FORGE_DOCUMENT = {
|
window.FORGE_DOCUMENT = {
|
||||||
slug: {{ support.slug|tojson }},
|
slug: {{ support.slug|tojson }},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Modèle de données du mini-jeu Scénario
|
"""Modèle de données du mini-jeu Scénario
|
||||||
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
||||||
directement sanitize_scenario_config, et le rendu du plateau."""
|
directement sanitize_scenario_config (arbre de décision), et le rendu
|
||||||
|
du plateau."""
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -11,91 +12,188 @@ def test_sanitize_scenario_config_on_missing_input_returns_full_defaults() -> No
|
|||||||
assert doc_engine.sanitize_scenario_config(None) == {"theme_color": "#ff5f2e", "scenarios": []}
|
assert doc_engine.sanitize_scenario_config(None) == {"theme_color": "#ff5f2e", "scenarios": []}
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_keeps_a_valid_scenario_as_is() -> None:
|
def test_sanitize_scenario_config_keeps_a_valid_tree_as_is() -> None:
|
||||||
raw = {
|
raw = {
|
||||||
"theme_color": "#123456",
|
"theme_color": "#123456",
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
{
|
{
|
||||||
"situation": "Un collègue vous demande son mot de passe.",
|
"title": "Mot de passe",
|
||||||
"choices": [
|
"nodes": [
|
||||||
{"text": "Le lui donner", "consequence": "Violation de sécurité."},
|
{
|
||||||
{"text": "Refuser et l'orienter vers l'IT", "consequence": "Bonne pratique respectée."},
|
"id": "n1",
|
||||||
|
"text": "Un collègue vous demande son mot de passe.",
|
||||||
|
"choices": [
|
||||||
|
{"text": "Le lui donner", "target_id": "n2"},
|
||||||
|
{"text": "Refuser", "target_id": None},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{"id": "n2", "text": "Violation de sécurité.", "choices": []},
|
||||||
],
|
],
|
||||||
"correct_index": 1,
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
config = doc_engine.sanitize_scenario_config(raw)
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
assert config["theme_color"] == "#123456"
|
assert config["theme_color"] == "#123456"
|
||||||
assert config["scenarios"] == [
|
assert config["scenarios"] == raw["scenarios"]
|
||||||
{
|
|
||||||
"situation": "Un collègue vous demande son mot de passe.",
|
|
||||||
"choices": [
|
|
||||||
{"text": "Le lui donner", "consequence": "Violation de sécurité."},
|
|
||||||
{"text": "Refuser et l'orienter vers l'IT", "consequence": "Bonne pratique respectée."},
|
|
||||||
],
|
|
||||||
"correct_index": 1,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_allows_an_empty_consequence() -> None:
|
def test_sanitize_scenario_config_drops_a_scenario_with_empty_title() -> None:
|
||||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": "B"}]}]}
|
raw = {"scenarios": [{"title": " ", "nodes": [{"id": "n1", "text": "Situation", "choices": []}]}]}
|
||||||
config = doc_engine.sanitize_scenario_config(raw)
|
|
||||||
assert config["scenarios"][0]["choices"] == [{"text": "A", "consequence": ""}, {"text": "B", "consequence": ""}]
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_drops_a_scenario_with_empty_situation() -> None:
|
|
||||||
raw = {"scenarios": [{"situation": " ", "choices": [{"text": "A"}, {"text": "B"}]}]}
|
|
||||||
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_drops_a_scenario_with_fewer_than_two_choices() -> None:
|
def test_sanitize_scenario_config_drops_a_scenario_with_no_valid_node() -> None:
|
||||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "Seul choix"}]}]}
|
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": " ", "choices": []}]}]}
|
||||||
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_drops_blank_choices_before_counting() -> None:
|
def test_sanitize_scenario_config_drops_a_node_missing_its_id() -> None:
|
||||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": " "}, {"text": ""}]}]}
|
|
||||||
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_caps_choices_at_max() -> None:
|
|
||||||
raw = {
|
raw = {
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
{
|
{
|
||||||
"situation": "Situation",
|
"title": "Titre",
|
||||||
"choices": [{"text": f"Choix {i}"} for i in range(doc_engine.MAX_SCENARIO_CHOICES + 3)],
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": []},
|
||||||
|
{"text": "Nœud sans id", "choices": []},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
config = doc_engine.sanitize_scenario_config(raw)
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
assert len(config["scenarios"][0]["choices"]) == doc_engine.MAX_SCENARIO_CHOICES
|
assert len(config["scenarios"][0]["nodes"]) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_resets_out_of_range_correct_index() -> None:
|
def test_sanitize_scenario_config_drops_a_node_with_empty_text() -> None:
|
||||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": "B"}], "correct_index": 7}]}
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": []},
|
||||||
|
{"id": "n2", "text": " ", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
config = doc_engine.sanitize_scenario_config(raw)
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
assert config["scenarios"][0]["correct_index"] == 0
|
assert [n["id"] for n in config["scenarios"][0]["nodes"]] == ["n1"]
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_rejects_a_boolean_as_correct_index() -> None:
|
def test_sanitize_scenario_config_drops_duplicate_node_ids() -> None:
|
||||||
"""True/False sont des int en Python (bool hérite de int) — même
|
raw = {
|
||||||
garde-fou que sanitize_quiz_config::correct_index."""
|
"scenarios": [
|
||||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": "B"}], "correct_index": True}]}
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": []},
|
||||||
|
{"id": "n1", "text": "Doublon", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
config = doc_engine.sanitize_scenario_config(raw)
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
assert config["scenarios"][0]["correct_index"] == 0
|
assert len(config["scenarios"][0]["nodes"]) == 1
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["text"] == "Situation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_drops_a_choice_with_empty_text() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Situation",
|
||||||
|
"choices": [{"text": " ", "target_id": None}, {"text": "Valide", "target_id": None}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert [c["text"] for c in config["scenarios"][0]["nodes"][0]["choices"]] == ["Valide"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_caps_choices_per_node_at_max() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Situation",
|
||||||
|
"choices": [
|
||||||
|
{"text": f"Choix {i}", "target_id": None}
|
||||||
|
for i in range(doc_engine.MAX_SCENARIO_CHOICES + 3)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert len(config["scenarios"][0]["nodes"][0]["choices"]) == doc_engine.MAX_SCENARIO_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_allows_a_node_with_zero_choices() -> None:
|
||||||
|
"""Un nœud sans choix est une fin de branche VALIDE — contrairement à
|
||||||
|
l'ancien modèle (2 à 4 choix obligatoires), il n'y a plus de notion
|
||||||
|
de bonne/mauvaise réponse à valider ici."""
|
||||||
|
raw = {"scenarios": [{"title": "Titre", "nodes": [{"id": "n1", "text": "Fin.", "choices": []}]}]}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["choices"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_nulls_a_target_id_pointing_to_a_missing_node() -> None:
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Situation",
|
||||||
|
"choices": [{"text": "Vers un nœud supprimé", "target_id": "n99"}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_scenario_config_keeps_a_target_id_pointing_to_a_later_node() -> None:
|
||||||
|
"""L'ordre des nœuds dans la liste ne contraint pas les références —
|
||||||
|
un choix du nœud 0 peut viser un nœud défini plus loin dans la liste
|
||||||
|
(voir le second passage de _sanitize_scenario, après avoir construit
|
||||||
|
l'ensemble complet des ids valides)."""
|
||||||
|
raw = {
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"title": "Titre",
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "text": "Situation", "choices": [{"text": "Continuer", "target_id": "n2"}]},
|
||||||
|
{"id": "n2", "text": "Suite", "choices": []},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
|
assert config["scenarios"][0]["nodes"][0]["choices"][0]["target_id"] == "n2"
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_keeps_multiple_scenarios_in_order() -> None:
|
def test_sanitize_scenario_config_keeps_multiple_scenarios_in_order() -> None:
|
||||||
raw = {
|
raw = {
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
{"situation": "Situation 1", "choices": [{"text": "A"}, {"text": "B"}]},
|
{"title": "Scénario 1", "nodes": [{"id": "n1", "text": "Situation 1", "choices": []}]},
|
||||||
{"situation": "Situation 2", "choices": [{"text": "C"}, {"text": "D"}]},
|
{"title": "Scénario 2", "nodes": [{"id": "n1", "text": "Situation 2", "choices": []}]},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
config = doc_engine.sanitize_scenario_config(raw)
|
config = doc_engine.sanitize_scenario_config(raw)
|
||||||
assert [s["situation"] for s in config["scenarios"]] == ["Situation 1", "Situation 2"]
|
assert [s["title"] for s in config["scenarios"]] == ["Scénario 1", "Scénario 2"]
|
||||||
|
|
||||||
|
|
||||||
def test_sanitize_scenario_config_ignores_garbage_top_level_input() -> None:
|
def test_sanitize_scenario_config_ignores_garbage_top_level_input() -> None:
|
||||||
@@ -117,9 +215,14 @@ def test_render_scenario_with_scenarios_includes_the_interactive_player() -> Non
|
|||||||
{
|
{
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
{
|
{
|
||||||
"situation": "Un collègue vous demande son mot de passe.",
|
"title": "Mot de passe",
|
||||||
"choices": [{"text": "Refuser"}, {"text": "Accepter"}],
|
"nodes": [
|
||||||
"correct_index": 0,
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"text": "Un collègue vous demande son mot de passe.",
|
||||||
|
"choices": [{"text": "Refuser", "target_id": None}],
|
||||||
|
}
|
||||||
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -131,13 +234,13 @@ def test_render_scenario_with_scenarios_includes_the_interactive_player() -> Non
|
|||||||
assert "1 scénario" in html
|
assert "1 scénario" in html
|
||||||
|
|
||||||
|
|
||||||
def test_render_scenario_escapes_situation_text_in_embedded_json() -> None:
|
def test_render_scenario_escapes_node_text_in_embedded_json() -> None:
|
||||||
config = doc_engine.sanitize_scenario_config(
|
config = doc_engine.sanitize_scenario_config(
|
||||||
{
|
{
|
||||||
"scenarios": [
|
"scenarios": [
|
||||||
{
|
{
|
||||||
"situation": '"><script>alert(1)</script>',
|
"title": "Titre",
|
||||||
"choices": [{"text": "A"}, {"text": "B"}],
|
"nodes": [{"id": "n1", "text": '"><script>alert(1)</script>', "choices": []}],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user