diff --git a/document_engine/__init__.py b/document_engine/__init__.py index f42eeca1..2495573c 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -35,12 +35,26 @@ from .labels.element_kind_labels import ( SHAPE_KINDS, element_default_attributes, ) +from .labels.quiz_config import ( + DEFAULT_QUIZ_CONFIG, + MAX_CHOICES, + MAX_TIMER_SECONDS, + MIN_CHOICES, + MIN_TIMER_SECONDS, + quiz_total_points, + sanitize_quiz_config, +) from .rendering.render_document_element import render_document, render_document_element __all__ = [ "CONTENT_KINDS", + "DEFAULT_QUIZ_CONFIG", "ELEMENT_KIND_LABELS", "ELEMENT_LIBRARY", + "MAX_CHOICES", + "MAX_TIMER_SECONDS", + "MIN_CHOICES", + "MIN_TIMER_SECONDS", "MINIGAME_KINDS", "SHAPE_KINDS", "add_document_element", @@ -49,7 +63,9 @@ __all__ = [ "get_document_element", "list_document_elements", "move_document_element", + "quiz_total_points", "render_document", "render_document_element", + "sanitize_quiz_config", "update_document_element_attributes", ] diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index 2b599a27..2bf0e4aa 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -5,6 +5,8 @@ défaut posés à la création de chaque type.""" from typing import Any +from .quiz_config import DEFAULT_QUIZ_CONFIG + SHAPE_KINDS = ("rectangle", "cercle", "triangle", "trait") CONTENT_KINDS = ("titre", "paragraphe", "image", "bouton") MINIGAME_KINDS = ("quiz", "association", "memory", "mots", "scenario", "zones") @@ -74,6 +76,14 @@ def element_default_attributes(kind: str) -> dict[str, Any]: return {"label": "Bouton", "target": ""} if kind == "row": return {"gap": 16, "align": "stretch", "justify": "flex-start"} + if kind == "quiz": + # Seul mini-jeu implémenté pour l'instant (voir quiz_config.py) — + # les autres restent un simple emplacement réservé ci-dessous. + # Copie de "questions" (liste, jamais un simple dict(...) qui la + # partagerait par référence avec DEFAULT_QUIZ_CONFIG) : aucun appelant + # ne la mute en place aujourd'hui, mais la copier ici coûte rien et + # évite d'ancrer cette hypothèse fragile pour la suite. + return {**DEFAULT_QUIZ_CONFIG, "questions": list(DEFAULT_QUIZ_CONFIG["questions"])} 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 3d483b7c..cc49c519 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -13,8 +13,9 @@ toujours en position absolue (`parent_id = NULL`). être top-niveau ou enfants d'une rangée. ## `MINIGAME_KINDS: tuple[str, ...]` -`("quiz", "association", "memory", "mots", "scenario", "zones")` — -panneau Propriétés minimal aujourd'hui (emplacement réservé), formulaires +`("quiz", "association", "memory", "mots", "scenario", "zones")` — seul +`"quiz"` est implémenté (voir `quiz_config.py` ci-dessous) ; les 5 autres +gardent un panneau Propriétés minimal (emplacement réservé), formulaires de contenu dédiés hors périmètre de cette passe. ## `ELEMENT_LIBRARY: dict[str, dict[str, Any]]` @@ -35,5 +36,39 @@ Attributs posés à la création d'un élément de ce type (voir formes (`x/y/width/height/rotation/z_index/fill/stroke/stroke_width/label`), texte (`content/style` + `bold/italic/underline/align/color`), image (`src/alt`), bouton (`label/target`), rangée (`gap/align/justify`), - mini-jeu (`theme_color`), ou `{}` pour un `kind` inconnu. + quiz (`DEFAULT_QUIZ_CONFIG`, voir `quiz_config.py`), autre mini-jeu + (`theme_color`), ou `{}` pour un `kind` inconnu. +- **Exceptions** : aucune. + +## `quiz_config.py` — modèle de données du mini-jeu Quiz + +Seul mini-jeu réellement implémenté (les autres `MINIGAME_KINDS` restent +un emplacement réservé). Même convention `resolve_X`/`sanitize_X` que +`game_engine/rendering/quiz_box_config.py` côté jeu, mais sans aucun +import croisé (voir `docs/plan/PLAN.md`). + +### `MIN_CHOICES`, `MAX_CHOICES`, `MIN_TIMER_SECONDS`, `MAX_TIMER_SECONDS`, `DEFAULT_TIMER_SECONDS: int` +Bornes de validation (`2`/`4` choix, `5`/`300` secondes, `30` par défaut). + +### `DEFAULT_QUIZ_CONFIG: dict[str, Any]` +`{"theme_color": "#ff5f2e", "timer_enabled": False, "timer_seconds": 30, "questions": []}`. + +### `sanitize_quiz_config(raw_config: Any) -> dict[str, Any]` +Valide/nettoie une config de quiz arbitraire (JSON venu du client) — +jamais lève, renvoie toujours un dict COMPLET fusionné sur +`DEFAULT_QUIZ_CONFIG`. Chaque question de `raw_config["questions"]` est +validée indépendamment (voir `_sanitize_question`, privée) : texte non +vide, au moins 2 choix non vides (tronqués à 4), `correct_index` remis à +0 s'il est absent/hors bornes/non-entier (un `bool` — qui est un `int` en +Python — est explicitement rejeté), `points` codé en entier ≥ 0. Une +question invalide est silencieusement supprimée de la liste (jamais une +levée qui ferait échouer tout le reste du quiz). +- **Retour** : dict complet (mêmes clés que `DEFAULT_QUIZ_CONFIG`). +- **Exceptions** : aucune. + +### `quiz_total_points(config: dict[str, Any]) -> int` +Somme du `points` de toutes les questions d'une config déjà sanitizée — +mirroir de `db/dialogue_lines.py::sum_question_rewards` côté jeu, utile +le jour où un export calculera un score maximum. +- **Retour** : entier ≥ 0. - **Exceptions** : aucune. diff --git a/document_engine/labels/quiz_config.py b/document_engine/labels/quiz_config.py new file mode 100644 index 00000000..5adadeb3 --- /dev/null +++ b/document_engine/labels/quiz_config.py @@ -0,0 +1,75 @@ +"""Modèle de données du mini-jeu Quiz (voir docs/plan/PLAN.md §3.2) — seul +mini-jeu implémenté pour l'instant (les 5 autres kinds de MINIGAME_KINDS +restent un simple emplacement réservé, voir element_kind_labels.py). Même +convention resolve_X/sanitize_X que game_engine/rendering/quiz_box_config.py +(distinct, aucun import croisé — voir docs/plan/PLAN.md, deux moteurs +isolés) : sanitize_quiz_config est pure, ne lève jamais, et renvoie +toujours un dict complet (jamais partiel) pour que le rendu puisse +supposer chaque clé présente.""" + +from typing import Any + +MIN_CHOICES = 2 +MAX_CHOICES = 4 +MIN_TIMER_SECONDS = 5 +MAX_TIMER_SECONDS = 300 +DEFAULT_TIMER_SECONDS = 30 + +DEFAULT_QUIZ_CONFIG: dict[str, Any] = { + "theme_color": "#ff5f2e", + "timer_enabled": False, + "timer_seconds": DEFAULT_TIMER_SECONDS, + "questions": [], +} + + +def _sanitize_question(raw: Any) -> dict[str, Any] | None: + """None si la question est invalide (texte vide, moins de 2 choix non + vides) — filtrée par sanitize_quiz_config plutôt que de faire échouer + tout le quiz, même convention que db/dialogue_lines.py:: + _sanitize_question_line côté jeu.""" + if not isinstance(raw, dict): + return None + text = str(raw.get("text", "")).strip() + if not text: + return None + raw_choices = raw.get("choices") + if not isinstance(raw_choices, list): + return None + choices = [str(c).strip() for c in raw_choices if str(c).strip()][:MAX_CHOICES] + if len(choices) < MIN_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 + try: + points = max(0, int(raw.get("points", 0))) + except (TypeError, ValueError): + points = 0 + return {"text": text, "choices": choices, "correct_index": correct_index, "points": points} + + +def sanitize_quiz_config(raw_config: Any) -> dict[str, Any]: + config = dict(DEFAULT_QUIZ_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 + config["timer_enabled"] = bool(raw_config.get("timer_enabled")) + try: + timer_seconds = int(raw_config.get("timer_seconds", DEFAULT_TIMER_SECONDS)) + except (TypeError, ValueError): + timer_seconds = DEFAULT_TIMER_SECONDS + config["timer_seconds"] = max(MIN_TIMER_SECONDS, min(MAX_TIMER_SECONDS, timer_seconds)) + raw_questions = raw_config.get("questions") + if isinstance(raw_questions, list): + config["questions"] = [q for q in (_sanitize_question(item) for item in raw_questions) if q is not None] + return config + + +def quiz_total_points(config: dict[str, Any]) -> int: + """Somme des points de toutes les questions — mirroir de + db/dialogue_lines.py::sum_question_rewards côté jeu, utile le jour où + un export calculera un score maximum.""" + return sum(int(q.get("points", 0)) for q in config.get("questions", [])) diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index 7d412315..89fe2195 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -136,6 +136,25 @@ def _render_minigame_placeholder( ) +def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: + from ..labels.quiz_config import quiz_total_points, sanitize_quiz_config + + config = sanitize_quiz_config(el["attributes"]) + theme_color = html_lib.escape(str(config["theme_color"])) + question_count = len(config["questions"]) + question_label = "question" if question_count <= 1 else "questions" + total_points = quiz_total_points(config) + timer_note = f" · ⏱ {config['timer_seconds']}s/question" if config["timer_enabled"] else "" + subtitle = f"{question_count} {question_label} · {total_points} points{timer_note}" + return ( + f'
' + f'Quiz' + f'{html_lib.escape(subtitle)}' + 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"])}
' @@ -150,7 +169,7 @@ _RENDERERS = { "paragraphe": _render_text, "image": _render_image, "bouton": _render_button, - "quiz": _render_minigame_placeholder, + "quiz": _render_quiz, "association": _render_minigame_placeholder, "memory": _render_minigame_placeholder, "mots": _render_minigame_placeholder, diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index 8e5f308d..e0e62e4e 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -35,7 +35,11 @@ regroupement à chaque appel. taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`. - **Image** : ``, ou un bloc placeholder si `src` est vide. - **Bouton** : ` + +
+ +
+
+ + +
+
${choicesHtml}
+
+ + +
+ + `; +} + +function forgeDocRenderQuizProps(panel, el) { + const a = el.attributes; + const questions = a.questions || []; + + function patch(partial) { + forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => { + forgeDocRenderProps(window.forgeDocState.elementsById[el.id]); + }); + } + + panel.innerHTML = ` + ${forgeDocPropsHeader('quiz')} +
+ + +
+ ${a.timer_enabled ? ` +
+ + +
` : ''} +
+ Couleur du thème +
+ ${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `
`).join('')} +
+
+
Questions
+
${questions.length ? questions.map(forgeDocRenderQuizQuestionHtml).join('') : '
Aucune question — ajoute la première ci-dessous.
'}
+ + ${forgeDocDeleteButtonHtml()} + `; + + document.getElementById('docQuizTimerEnabled').addEventListener('change', (e) => patch({ timer_enabled: e.target.checked })); + const timerSecondsInput = document.getElementById('docQuizTimerSeconds'); + if (timerSecondsInput) timerSecondsInput.addEventListener('change', (e) => patch({ timer_seconds: Number(e.target.value) })); + panel.querySelectorAll('#docQuizColor .docSwatch').forEach((sw) => { + sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color })); + }); + + document.getElementById('docQuizAddQuestion').addEventListener('click', () => { + patch({ questions: [...questions, forgeDocQuizNewQuestion()] }); + }); + + panel.querySelectorAll('.docQuizRemoveBtn').forEach((btn) => { + btn.addEventListener('click', () => { + const qIndex = Number(btn.dataset.questionIndex); + patch({ questions: questions.filter((_, i) => i !== qIndex) }); + }); + }); + + panel.querySelectorAll('.docQuizTextInput').forEach((input) => { + input.addEventListener('change', (e) => { + const qIndex = Number(input.dataset.questionIndex); + patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, text: e.target.value } : q)) }); + }); + }); + + panel.querySelectorAll('.docQuizChoiceCountInput').forEach((input) => { + input.addEventListener('change', (e) => { + const qIndex = Number(input.dataset.questionIndex); + const count = Math.max(2, Math.min(4, Number(e.target.value) || 2)); + patch({ + questions: questions.map((q, i) => { + if (i !== qIndex) return q; + const choices = q.choices.slice(0, count); + while (choices.length < count) choices.push(`Choix ${choices.length + 1}`); + return { ...q, choices, correct_index: q.correct_index < count ? q.correct_index : 0 }; + }), + }); + }); + }); + + panel.querySelectorAll('.docQuizChoiceInput').forEach((input) => { + input.addEventListener('change', (e) => { + const qIndex = Number(input.dataset.questionIndex); + const cIndex = Number(input.dataset.choiceIndex); + patch({ + questions: questions.map((q, i) => ( + i === qIndex ? { ...q, choices: q.choices.map((c, ci) => (ci === cIndex ? e.target.value : c)) } : q + )), + }); + }); + }); + + panel.querySelectorAll('.docQuizCorrectRadio').forEach((radio) => { + radio.addEventListener('change', () => { + const qIndex = Number(radio.dataset.questionIndex); + const cIndex = Number(radio.dataset.choiceIndex); + patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, correct_index: cIndex } : q)) }); + }); + }); + + panel.querySelectorAll('.docQuizPointsInput').forEach((input) => { + input.addEventListener('change', (e) => { + const qIndex = Number(input.dataset.questionIndex); + const points = Math.max(0, Number(e.target.value) || 0); + patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, points } : q)) }); + }); + }); +} + function forgeDocRenderProps(el) { const panel = document.getElementById('docPropsPanel'); if (!el) { @@ -664,6 +815,7 @@ function forgeDocRenderProps(el) { else if (el.kind === 'image') forgeDocRenderImageProps(panel, el); else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el); else if (el.kind === 'row') forgeDocRenderRowProps(panel, el); + else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el); else forgeDocRenderMinigameProps(panel, el); const deleteBtn = document.getElementById('docDeletePropBtn'); diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index 3eacfc06..174e2282 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -65,6 +65,34 @@ def test_document_element_update_persists_attributes(client: FlaskClient, suppor assert reread.status_code == 200 +def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None: + """Le quiz est le seul kind revalidé côté serveur (voir + routes/document/document_element_update.py) : un choix en trop + (5e) doit être tronqué, et la réponse renvoie les attributs + RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le + client ne dérive jamais de la vérité serveur.""" + add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz"}) + element_id = add_resp.get_json()["id"] + + resp = client.post( + f"/document/{support}/elements/{element_id}/update", + json={ + "theme_color": "#ff5f2e", + "timer_enabled": False, + "timer_seconds": 30, + "questions": [ + {"text": "Une question", "choices": ["A", "B", "C", "D", "E"], "correct_index": 0, "points": 10}, + ], + }, + ) + assert resp.status_code == 200 + payload = resp.get_json() + assert payload["ok"] is True + assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"] + assert "1 question" in payload["rendered_html"] + assert "10 points" in payload["rendered_html"] + + def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None: first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"] second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).get_json()["id"] diff --git a/tests/document/test_quiz_config.py b/tests/document/test_quiz_config.py new file mode 100644 index 00000000..23d2e3e5 --- /dev/null +++ b/tests/document/test_quiz_config.py @@ -0,0 +1,101 @@ +"""Modèle de données du mini-jeu Quiz (document_engine/labels/quiz_config.py) +— sans Flask, teste directement sanitize_quiz_config/quiz_total_points.""" + +import document_engine as doc_engine + + +def test_sanitize_quiz_config_on_missing_input_returns_full_defaults() -> None: + config = doc_engine.sanitize_quiz_config(None) + assert config == { + "theme_color": "#ff5f2e", + "timer_enabled": False, + "timer_seconds": 30, + "questions": [], + } + + +def test_sanitize_quiz_config_keeps_a_valid_question_as_is() -> None: + raw = { + "theme_color": "#123456", + "timer_enabled": True, + "timer_seconds": 45, + "questions": [ + {"text": "2 + 2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "points": 10}, + ], + } + config = doc_engine.sanitize_quiz_config(raw) + assert config["theme_color"] == "#123456" + assert config["timer_enabled"] is True + assert config["timer_seconds"] == 45 + assert config["questions"] == [{"text": "2 + 2 ?", "choices": ["3", "4", "5"], "correct_index": 1, "points": 10}] + + +def test_sanitize_quiz_config_drops_a_question_with_empty_text() -> None: + raw = {"questions": [{"text": " ", "choices": ["A", "B"], "correct_index": 0, "points": 5}]} + assert doc_engine.sanitize_quiz_config(raw)["questions"] == [] + + +def test_sanitize_quiz_config_drops_a_question_with_fewer_than_two_choices() -> None: + raw = {"questions": [{"text": "Une question", "choices": ["Seul choix"], "correct_index": 0, "points": 5}]} + assert doc_engine.sanitize_quiz_config(raw)["questions"] == [] + + +def test_sanitize_quiz_config_drops_blank_choices_before_counting() -> None: + raw = {"questions": [{"text": "Une question", "choices": ["A", " ", ""], "correct_index": 0, "points": 5}]} + assert doc_engine.sanitize_quiz_config(raw)["questions"] == [] + + +def test_sanitize_quiz_config_caps_choices_at_four() -> None: + raw = { + "questions": [ + {"text": "Une question", "choices": ["A", "B", "C", "D", "E"], "correct_index": 0, "points": 5}, + ], + } + config = doc_engine.sanitize_quiz_config(raw) + assert config["questions"][0]["choices"] == ["A", "B", "C", "D"] + + +def test_sanitize_quiz_config_resets_out_of_range_correct_index() -> None: + raw = {"questions": [{"text": "Une question", "choices": ["A", "B"], "correct_index": 7, "points": 5}]} + config = doc_engine.sanitize_quiz_config(raw) + assert config["questions"][0]["correct_index"] == 0 + + +def test_sanitize_quiz_config_rejects_a_boolean_as_correct_index() -> None: + """True/False sont des int en Python (bool hérite de int) — un JSON + malveillant/malformé pourrait glisser `true` là où un index est + attendu, ce qui vaudrait 1 après coercition implicite sans ce garde-fou + explicite.""" + raw = {"questions": [{"text": "Une question", "choices": ["A", "B"], "correct_index": True, "points": 5}]} + config = doc_engine.sanitize_quiz_config(raw) + assert config["questions"][0]["correct_index"] == 0 + + +def test_sanitize_quiz_config_clamps_negative_points_to_zero() -> None: + raw = {"questions": [{"text": "Une question", "choices": ["A", "B"], "correct_index": 0, "points": -5}]} + assert doc_engine.sanitize_quiz_config(raw)["questions"][0]["points"] == 0 + + +def test_sanitize_quiz_config_clamps_timer_seconds_into_bounds() -> None: + assert doc_engine.sanitize_quiz_config({"timer_seconds": 1})["timer_seconds"] == doc_engine.MIN_TIMER_SECONDS + assert doc_engine.sanitize_quiz_config({"timer_seconds": 9999})["timer_seconds"] == doc_engine.MAX_TIMER_SECONDS + + +def test_sanitize_quiz_config_ignores_garbage_top_level_input() -> None: + assert doc_engine.sanitize_quiz_config("n'importe quoi") == doc_engine.DEFAULT_QUIZ_CONFIG + + +def test_quiz_total_points_sums_every_question() -> None: + config = doc_engine.sanitize_quiz_config( + { + "questions": [ + {"text": "Q1", "choices": ["A", "B"], "correct_index": 0, "points": 10}, + {"text": "Q2", "choices": ["A", "B"], "correct_index": 1, "points": 5}, + ] + } + ) + assert doc_engine.quiz_total_points(config) == 15 + + +def test_quiz_total_points_on_empty_quiz_is_zero() -> None: + assert doc_engine.quiz_total_points(doc_engine.DEFAULT_QUIZ_CONFIG) == 0