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
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:
co-authored by
Claude Sonnet 5
parent
d8be80ebd1
commit
b2292de122
@@ -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") —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -14,16 +14,16 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
|
||||
Propriétés (voir static/document/js/document-editor.js) envoie
|
||||
systématiquement l'état complet de ses champs.
|
||||
|
||||
Quiz, Association, Memory et Mots mêlés sont les seuls kinds
|
||||
revalidés côté serveur (sanitize_quiz_config/
|
||||
Quiz, Association, Memory, Mots mêlés et Scénario sont les seuls
|
||||
kinds revalidés côté serveur (sanitize_quiz_config/
|
||||
sanitize_association_config/sanitize_memory_config/
|
||||
sanitize_mots_config, même convention que
|
||||
sanitize_mots_config/sanitize_scenario_config, même convention que
|
||||
game_engine/rendering/quiz_box_config.py côté jeu) : leur forme (liste
|
||||
de questions/choix, liste de paires, liste de cartes, liste de mots)
|
||||
doit rester structurellement correcte pour que le rendu ne plante
|
||||
jamais, contrairement aux autres kinds (texte/forme/image...) dont
|
||||
les attributs sont de simples valeurs scalaires sans structure à
|
||||
garantir."""
|
||||
de questions/choix, liste de paires, liste de cartes, liste de mots,
|
||||
liste de scénarios) doit rester structurellement correcte pour que le
|
||||
rendu ne plante jamais, contrairement aux autres kinds
|
||||
(texte/forme/image...) dont les attributs sont de simples valeurs
|
||||
scalaires sans structure à garantir."""
|
||||
element = document_engine.get_document_element(slug, element_id)
|
||||
if element is None:
|
||||
return jsonify({"error": "élément introuvable"}), 404
|
||||
@@ -36,6 +36,8 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
|
||||
attributes = document_engine.sanitize_memory_config(attributes)
|
||||
elif element["kind"] == "mots":
|
||||
attributes = document_engine.sanitize_mots_config(attributes)
|
||||
elif element["kind"] == "scenario":
|
||||
attributes = document_engine.sanitize_scenario_config(attributes)
|
||||
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||
element = db.assert_not_none(
|
||||
document_engine.get_document_element(slug, element_id),
|
||||
|
||||
@@ -500,36 +500,40 @@ img.docImage {
|
||||
color: var(--doc-muted);
|
||||
}
|
||||
|
||||
/* ---- Quiz/Association/Memory/Mots mêlés : mini-jeux interactifs en Mode
|
||||
Aperçu (voir docs/plan/maquettes/document-formation-web.html —
|
||||
référence visuelle du Quiz). Masqués en édition (seul
|
||||
.docMinigameBadge y est visible, voir plus haut) ; jamais les deux
|
||||
visibles en même temps. ---- */
|
||||
/* ---- Quiz/Association/Memory/Mots mêlés/Scénario : mini-jeux
|
||||
interactifs en Mode Aperçu (voir docs/plan/maquettes/
|
||||
document-formation-web.html — référence visuelle du Quiz). Masqués en
|
||||
édition (seul .docMinigameBadge y est visible, voir plus haut) ;
|
||||
jamais les deux visibles en même temps. ---- */
|
||||
.docQuizPlayer,
|
||||
.docAssocPlayer,
|
||||
.docMemoryPlayer,
|
||||
.docMotsPlayer {
|
||||
.docMotsPlayer,
|
||||
.docScenarioPlayer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge {
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMinigameBadge,
|
||||
.docEditor3.docEditor3--preview [data-kind="scenario"] .docMinigameBadge {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="association"] .docAssocPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"] .docMemoryPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer {
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] .docMotsPlayer,
|
||||
.docEditor3.docEditor3--preview [data-kind="scenario"] .docScenarioPlayer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.docEditor3.docEditor3--preview [data-kind="quiz"],
|
||||
.docEditor3.docEditor3--preview [data-kind="association"],
|
||||
.docEditor3.docEditor3--preview [data-kind="memory"],
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"] {
|
||||
.docEditor3.docEditor3--preview [data-kind="mots"],
|
||||
.docEditor3.docEditor3--preview [data-kind="scenario"] {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
@@ -541,7 +545,9 @@ img.docImage {
|
||||
.docEditor3.docEditor3--preview .docMemoryPlayer,
|
||||
.docEditor3.docEditor3--preview .docMemoryPlayer *,
|
||||
.docEditor3.docEditor3--preview .docMotsPlayer,
|
||||
.docEditor3.docEditor3--preview .docMotsPlayer * {
|
||||
.docEditor3.docEditor3--preview .docMotsPlayer *,
|
||||
.docEditor3.docEditor3--preview .docScenarioPlayer,
|
||||
.docEditor3.docEditor3--preview .docScenarioPlayer * {
|
||||
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
|
||||
(voir plus bas) pour empêcher la sélection/le déplacement pendant
|
||||
l'Aperçu — réactivé ici spécifiquement pour que ces mini-jeux restent
|
||||
@@ -1312,6 +1318,17 @@ img.docImage {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ---- Scénario : panneau Propriétés — chaque choix garde sa case "bon
|
||||
choix"/son texte (.docQuizChoiceRow, réutilisé tel quel) ET, en plus,
|
||||
sa conséquence en dessous (voir forgeDocRenderScenarioChoiceHtml,
|
||||
static/document/js/document-editor.js) — un bloc de plus que le Quiz,
|
||||
d'où ce conteneur dédié. ---- */
|
||||
.docScenarioChoiceBlock {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.docQuizAddBtn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
|
||||
@@ -479,6 +479,7 @@ function forgeDocBindCanvasInteractions() {
|
||||
forgeDocInitAssociationPlayers();
|
||||
forgeDocInitMemoryPlayers();
|
||||
forgeDocInitMotsPlayers();
|
||||
forgeDocInitScenarioPlayers();
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
@@ -1135,6 +1136,110 @@ function forgeDocInitMotsPlayers() {
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
|
||||
* l'apprenant lit une situation, choisit une option, découvre la
|
||||
* conséquence de SON choix ainsi que si c'était le bon, puis passe au
|
||||
* scénario suivant. Les scénarios se jouent dans l'ORDRE d'écriture
|
||||
* (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés) :
|
||||
* ce sont des mises en situation séquentielles. Comme ces trois
|
||||
* mini-jeux, le dernier scénario reste affiché une fois répondu : 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) {
|
||||
try {
|
||||
const config = JSON.parse(playerEl.dataset.scenarioConfig || '{}');
|
||||
return Array.isArray(config.scenarios) ? config.scenarios : [];
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocRenderScenarioPlayerScenario(playerEl, state) {
|
||||
state.answered = false;
|
||||
const item = state.scenarios[state.current];
|
||||
const situationEl = playerEl.querySelector('.docScenarioSituation');
|
||||
const choicesEl = playerEl.querySelector('.docScenarioChoices');
|
||||
const consequenceEl = playerEl.querySelector('.docScenarioConsequence');
|
||||
const progEl = playerEl.querySelector('.docScenarioProg');
|
||||
const nextBtn = playerEl.querySelector('.docScenarioNextBtn');
|
||||
|
||||
situationEl.textContent = item.situation;
|
||||
choicesEl.innerHTML = '';
|
||||
consequenceEl.className = 'docScenarioConsequence docQuizFeedback';
|
||||
consequenceEl.textContent = '';
|
||||
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, btn);
|
||||
});
|
||||
choicesEl.appendChild(btn);
|
||||
});
|
||||
progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length}`;
|
||||
}
|
||||
|
||||
function forgeDocScenarioPlayerAnswer(playerEl, state, idx, btn) {
|
||||
if (state.answered) return;
|
||||
state.answered = true;
|
||||
const item = state.scenarios[state.current];
|
||||
const buttons = playerEl.querySelectorAll('.docScenarioChoice');
|
||||
buttons.forEach((b) => { b.disabled = true; });
|
||||
const consequenceEl = playerEl.querySelector('.docScenarioConsequence');
|
||||
const isLast = state.current === state.scenarios.length - 1;
|
||||
if (idx === item.correct_index) {
|
||||
btn.classList.add('is-correct');
|
||||
consequenceEl.classList.add('is-visible', 'is-ok');
|
||||
} else {
|
||||
btn.classList.add('is-wrong');
|
||||
buttons[item.correct_index].classList.add('is-correct');
|
||||
consequenceEl.classList.add('is-visible', 'is-ko');
|
||||
}
|
||||
consequenceEl.textContent = item.choices[idx].consequence || 'Aucune conséquence renseignée pour ce choix.';
|
||||
if (isLast) {
|
||||
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
||||
} else {
|
||||
playerEl.querySelector('.docScenarioNextBtn').classList.add('is-visible');
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocScenarioPlayerNext(playerEl, state) {
|
||||
state.current += 1;
|
||||
if (state.current < state.scenarios.length) {
|
||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
||||
}
|
||||
}
|
||||
|
||||
function forgeDocScenarioRestart(playerEl, state) {
|
||||
state.current = 0;
|
||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
||||
}
|
||||
|
||||
function forgeDocInitScenarioPlayers() {
|
||||
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
|
||||
const scenarios = forgeDocScenarioPlayerData(playerEl);
|
||||
if (!scenarios.length) return;
|
||||
const state = { current: 0, answered: false, scenarios };
|
||||
forgeDocRenderScenarioPlayerScenario(playerEl, state);
|
||||
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocScenarioPlayerNext(playerEl, state);
|
||||
});
|
||||
playerEl.querySelector('.docScenarioRestartBtn').addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
forgeDocScenarioRestart(playerEl, state);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocSetActiveBottomNav(target) {
|
||||
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
||||
b.classList.toggle('is-active', b.dataset.target === target);
|
||||
@@ -1737,6 +1842,179 @@ function forgeDocRenderMotsProps(panel, el) {
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocScenarioNewScenario() {
|
||||
// Champs pré-remplis (jamais vides) : sanitize_scenario_config
|
||||
// (routes/document/document_element_update.py) rejette silencieusement
|
||||
// tout scénario dont la situation est vide, ou tout choix dont le
|
||||
// texte est vide — même leçon que forgeDocAssociationNewPair/
|
||||
// forgeDocMemoryNewCard/l'ajout de mot des Mots mêlés.
|
||||
return {
|
||||
situation: '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) {
|
||||
return `
|
||||
<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) {
|
||||
const choicesHtml = scenario.choices
|
||||
.map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index))
|
||||
.join('');
|
||||
return `
|
||||
<div class="docQuizQuestion docScenarioCard" data-scenario-index="${sIndex}">
|
||||
<div class="docQuizQuestionHeader">
|
||||
<span class="docFieldLabel">Scénario ${sIndex + 1}</span>
|
||||
<button type="button" class="docQuizRemoveBtn docScenarioRemoveBtn" data-scenario-index="${sIndex}" title="Supprimer ce scénario">🗑</button>
|
||||
</div>
|
||||
<div class="docField">
|
||||
<textarea class="docScenarioSituationInput"
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
function forgeDocRenderScenarioProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
const scenarios = a.scenarios || [];
|
||||
|
||||
function patch(partial) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
||||
});
|
||||
}
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('scénario')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Couleur du thème</span>
|
||||
<div class="docSwatches" id="docScenarioColor">
|
||||
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="docSectionLabel docQuizQuestionsLabel">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>
|
||||
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioAdd">+ Ajouter un scénario</button>
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
|
||||
panel.querySelectorAll('#docScenarioColor .docSwatch').forEach((sw) => {
|
||||
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
||||
});
|
||||
|
||||
document.getElementById('docScenarioAdd').addEventListener('click', () => {
|
||||
patch({ scenarios: [...scenarios, forgeDocScenarioNewScenario()] });
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioRemoveBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const sIndex = Number(btn.dataset.scenarioIndex);
|
||||
patch({ scenarios: scenarios.filter((_, i) => i !== sIndex) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioSituationInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, situation: e.target.value } : s)) });
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioChoiceCountInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
|
||||
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) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
const cIndex = Number(input.dataset.choiceIndex);
|
||||
patch({
|
||||
scenarios: scenarios.map((s, i) => (
|
||||
i === sIndex
|
||||
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
||||
: s
|
||||
)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioConsequenceInput').forEach((input) => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const sIndex = Number(input.dataset.scenarioIndex);
|
||||
const cIndex = Number(input.dataset.choiceIndex);
|
||||
patch({
|
||||
scenarios: scenarios.map((s, i) => (
|
||||
i === sIndex
|
||||
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, consequence: e.target.value } : c)) }
|
||||
: s
|
||||
)),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
panel.querySelectorAll('.docScenarioCorrectRadio').forEach((radio) => {
|
||||
radio.addEventListener('change', () => {
|
||||
const sIndex = Number(radio.dataset.scenarioIndex);
|
||||
const cIndex = Number(radio.dataset.choiceIndex);
|
||||
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, correct_index: cIndex } : s)) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function forgeDocRenderProps(el) {
|
||||
const panel = document.getElementById('docPropsPanel');
|
||||
if (!el) {
|
||||
@@ -1752,6 +2030,7 @@ function forgeDocRenderProps(el) {
|
||||
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
||||
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
||||
else if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
|
||||
else if (el.kind === 'scenario') forgeDocRenderScenarioProps(panel, el);
|
||||
else forgeDocRenderMinigameProps(panel, el);
|
||||
|
||||
const deleteBtn = document.getElementById('docDeletePropBtn');
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Modèle de données du mini-jeu Scénario
|
||||
(document_engine/labels/scenario_config.py) — sans Flask, teste
|
||||
directement sanitize_scenario_config, et le rendu du plateau."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import document_engine as doc_engine
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_on_missing_input_returns_full_defaults() -> None:
|
||||
assert doc_engine.sanitize_scenario_config(None) == {"theme_color": "#ff5f2e", "scenarios": []}
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_keeps_a_valid_scenario_as_is() -> None:
|
||||
raw = {
|
||||
"theme_color": "#123456",
|
||||
"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,
|
||||
}
|
||||
],
|
||||
}
|
||||
config = doc_engine.sanitize_scenario_config(raw)
|
||||
assert config["theme_color"] == "#123456"
|
||||
assert config["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:
|
||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": "B"}]}]}
|
||||
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"] == []
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_drops_a_scenario_with_fewer_than_two_choices() -> None:
|
||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "Seul choix"}]}]}
|
||||
assert doc_engine.sanitize_scenario_config(raw)["scenarios"] == []
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_drops_blank_choices_before_counting() -> 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 = {
|
||||
"scenarios": [
|
||||
{
|
||||
"situation": "Situation",
|
||||
"choices": [{"text": f"Choix {i}"} for i in range(doc_engine.MAX_SCENARIO_CHOICES + 3)],
|
||||
}
|
||||
]
|
||||
}
|
||||
config = doc_engine.sanitize_scenario_config(raw)
|
||||
assert len(config["scenarios"][0]["choices"]) == doc_engine.MAX_SCENARIO_CHOICES
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_resets_out_of_range_correct_index() -> None:
|
||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": "B"}], "correct_index": 7}]}
|
||||
config = doc_engine.sanitize_scenario_config(raw)
|
||||
assert config["scenarios"][0]["correct_index"] == 0
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_rejects_a_boolean_as_correct_index() -> None:
|
||||
"""True/False sont des int en Python (bool hérite de int) — même
|
||||
garde-fou que sanitize_quiz_config::correct_index."""
|
||||
raw = {"scenarios": [{"situation": "Situation", "choices": [{"text": "A"}, {"text": "B"}], "correct_index": True}]}
|
||||
config = doc_engine.sanitize_scenario_config(raw)
|
||||
assert config["scenarios"][0]["correct_index"] == 0
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_keeps_multiple_scenarios_in_order() -> None:
|
||||
raw = {
|
||||
"scenarios": [
|
||||
{"situation": "Situation 1", "choices": [{"text": "A"}, {"text": "B"}]},
|
||||
{"situation": "Situation 2", "choices": [{"text": "C"}, {"text": "D"}]},
|
||||
]
|
||||
}
|
||||
config = doc_engine.sanitize_scenario_config(raw)
|
||||
assert [s["situation"] for s in config["scenarios"]] == ["Situation 1", "Situation 2"]
|
||||
|
||||
|
||||
def test_sanitize_scenario_config_ignores_garbage_top_level_input() -> None:
|
||||
assert doc_engine.sanitize_scenario_config("n'importe quoi") == doc_engine.DEFAULT_SCENARIO_CONFIG
|
||||
|
||||
|
||||
def _scenario_element(attributes: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"id": 1, "kind": "scenario", "parent_id": None, "order_index": 0, "attributes": attributes}
|
||||
|
||||
|
||||
def test_render_scenario_without_scenarios_shows_only_the_badge() -> None:
|
||||
html = doc_engine.render_document_element(_scenario_element(doc_engine.DEFAULT_SCENARIO_CONFIG), {})
|
||||
assert "0 scénario" in html
|
||||
assert "docScenarioPlayer" not in html
|
||||
|
||||
|
||||
def test_render_scenario_with_scenarios_includes_the_interactive_player() -> None:
|
||||
config = doc_engine.sanitize_scenario_config(
|
||||
{
|
||||
"scenarios": [
|
||||
{
|
||||
"situation": "Un collègue vous demande son mot de passe.",
|
||||
"choices": [{"text": "Refuser"}, {"text": "Accepter"}],
|
||||
"correct_index": 0,
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
html = doc_engine.render_document_element(_scenario_element(config), {})
|
||||
assert "docScenarioPlayer" in html
|
||||
assert "docQuizOptions" in html
|
||||
assert "data-scenario-config=" in html
|
||||
assert "1 scénario" in html
|
||||
|
||||
|
||||
def test_render_scenario_escapes_situation_text_in_embedded_json() -> None:
|
||||
config = doc_engine.sanitize_scenario_config(
|
||||
{
|
||||
"scenarios": [
|
||||
{
|
||||
"situation": '"><script>alert(1)</script>',
|
||||
"choices": [{"text": "A"}, {"text": "B"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
html = doc_engine.render_document_element(_scenario_element(config), {})
|
||||
assert "<script>alert(1)</script>" not in html
|
||||
assert "<script>" in html
|
||||
Reference in New Issue
Block a user