diff --git a/document_engine/__init__.py b/document_engine/__init__.py index be75403d..a91ec5f9 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -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", ] diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index fb6d4768..ed2cdd59 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -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") — diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index 3876f5bc..13ca82e0 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -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. diff --git a/document_engine/labels/scenario_config.py b/document_engine/labels/scenario_config.py new file mode 100644 index 00000000..f6879cba --- /dev/null +++ b/document_engine/labels/scenario_config.py @@ -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 diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index 507d4010..1904c53b 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -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'
' + f'
' + f'
Scénario
' + f'
' + f'
' + f'
' + f'
' + f'
' + f'' + f"
" + # 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'
' + f'' + f"
" + f"
" + ) + + +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'
' + f'
' + f'Scénario' + f'{scenario_count} {scenario_label}' + f"
" + f"{player_html}" + f"
" + ) + + def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: return f'
Type inconnu : {html_lib.escape(el["kind"])}
' @@ -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, } diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index c27bf369..94bdb477 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -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. diff --git a/routes/document/document_element_update.py b/routes/document/document_element_update.py index 66d173a3..cdd5393e 100644 --- a/routes/document/document_element_update.py +++ b/routes/document/document_element_update.py @@ -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), diff --git a/static/document/document-editor.css b/static/document/document-editor.css index 669fef02..3466b22d 100644 --- a/static/document/document-editor.css +++ b/static/document/document-editor.css @@ -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; diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index e1cd7384..ff6fda59 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -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 ` +
+
+ + +
+ +
+ `; +} + +function forgeDocRenderScenarioHtml(scenario, sIndex) { + const choicesHtml = scenario.choices + .map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index)) + .join(''); + return ` +
+
+ Scénario ${sIndex + 1} + +
+
+ +
+
+ + +
+
${choicesHtml}
+
+ `; +} + +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')} +
+ Couleur du thème +
+ ${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `
`).join('')} +
+
+
Scénarios
+
${scenarios.length ? scenarios.map(forgeDocRenderScenarioHtml).join('') : '
Aucun scénario — ajoute le premier ci-dessous.
'}
+ + ${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'); diff --git a/tests/document/test_scenario_config.py b/tests/document/test_scenario_config.py new file mode 100644 index 00000000..09643328 --- /dev/null +++ b/tests/document/test_scenario_config.py @@ -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": '">', + "choices": [{"text": "A"}, {"text": "B"}], + } + ] + } + ) + html = doc_engine.render_document_element(_scenario_element(config), {}) + assert "" not in html + assert "<script>" in html