Files
Forge-Engine/document_engine/labels/quiz_config.py
T
williamandClaude Sonnet 5 9d87a4bfef
Build and deploy / test-python (push) Failing after 1m3s
Build and deploy / test-js (push) Successful in 48s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m5s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m5s
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 <noreply@anthropic.com>
2026-09-20 11:40:48 +02:00

76 lines
3.1 KiB
Python

"""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", []))