From 9d87a4bfefb8b076b96d4bc64244e2892c2e0984 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 20 Sep 2026 11:40:48 +0200 Subject: [PATCH] Implemente le mini-jeu Quiz (questions/choix/points/timer) document_engine/labels/quiz_config.py (nouveau) : modele de donnees complet, meme convention resolve_X/sanitize_X que game_engine/rendering/quiz_box_config.py cote jeu (aucun import croise) - DEFAULT_QUIZ_CONFIG, sanitize_quiz_config (valide/nettoie chaque question independamment, jamais ne leve, tronque a 2-4 choix, remet correct_index a 0 si hors bornes/non-entier, clampe points >= 0 et timer_seconds dans [5,300]), quiz_total_points. Cote serveur, routes/document/document_element_update.py revalide desormais un quiz avant persistance (seul kind qui en a besoin - les autres n'ont que des attributs scalaires sans structure a garantir) et renvoie les attributs REELLEMENT persistes dans sa reponse, pour que le client ne derive jamais de la verite serveur apres un nettoyage serveur (ex. choix en trop tronque). Cote editeur (static/document/js/document-editor.js), le panneau Proprietes d'un quiz est desormais reel : chronometre optionnel, couleur de theme, liste de questions repetable (ajout/suppression), chacune avec son texte, un nombre de choix ajustable (2-4, les inputs texte suivent), le choix correct via un radio par question, et les points gagnes. Le rendu canevas (render_document_element.py) affiche un resume reel (nombre de questions, total des points, minuteur) au lieu du placeholder generique. Tests : document_engine/labels/quiz_config.py couvert par 13 tests purs (tests/document/test_quiz_config.py, sans Flask - defauts, troncature, validation, cas limites dont bool comme correct_index) + 1 test de route verifiant la sanitization a l'ecriture et le rendu. SKIP=djlint : backlog H021 pre-existant, aucun template touche ici. ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous verts ; 63 tests cibles (document + onboarding + auth) verifies fraichement + verification manuelle live via le serveur de dev (ajout, sanitization sur choix invalides/en trop, rendu du resume avec minuteur). Co-Authored-By: Claude Sonnet 5 --- document_engine/__init__.py | 16 ++ document_engine/labels/element_kind_labels.py | 10 ++ document_engine/labels/labels.md | 41 ++++- document_engine/labels/quiz_config.py | 75 +++++++++ .../rendering/render_document_element.py | 21 ++- document_engine/rendering/rendering.md | 6 +- routes/document/document_element_update.py | 16 +- static/document/document-editor.css | 57 +++++++ static/document/js/document-editor.js | 154 +++++++++++++++++- tests/document/test_document_routes.py | 28 ++++ tests/document/test_quiz_config.py | 101 ++++++++++++ 11 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 document_engine/labels/quiz_config.py create mode 100644 tests/document/test_quiz_config.py 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