Files
williamandClaude Sonnet 5 9fb22be1ae
Build and deploy / test-python (push) Failing after 1m5s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m4s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m0s
Quiz reellement interactif en Mode Apercu (voir maquette document-formation-web.html)
Le quiz affichait jusqu'ici la meme carte resume en edition comme en
Apercu. Ajoute un vrai questionnaire jouable, visible UNIQUEMENT en
Mode Apercu (masque en edition via CSS, jamais les deux a la fois) :
options de reponse cliquables, marquage correct/incorrect immediat,
score qui accumule les points de chaque question repondue
correctement, progression "Question suivante", et un ecran de
resultat final (score obtenu / total des points possibles) avec
"Recommencer le quiz" - structure et identite visuelle calquees sur
docs/plan/maquettes/document-formation-web.html (cartes en degrade,
options A/B/C/D, feedback vert/orange), adaptees aux tokens --doc-*
deja en place (theme clair/sombre inclus, nouvelles variables
--doc-quiz-success-*/--doc-quiz-danger-*).

Cote rendu (document_engine/rendering/render_document_element.py),
_render_quiz ajoute desormais le questionnaire (fonction privee
_render_quiz_player) des qu'au moins une question existe : les
questions sont embarquees en JSON dans un attribut data-quiz-config
(jamais un <script> par element), echappees pour l'HTML - aucun
aller-retour serveur pendant qu'on joue, tout le deroulé (reponse/
score/suivant/resultat) est gere par
static/document/js/document-editor.js. pointer-events, desactive sur
tout element en Apercu pour empecher la selection/le glisser-deposer
pendant le test, est reactive specifiquement dans le questionnaire
pour qu'il reste reellement cliquable.

Tests : 3 nouveaux tests de rendu purs (sans Flask, dont un qui
verifie l'echappement HTML du texte d'une question contre une
injection). Verifie en live via une simulation DOM complete
(basculer Apercu, repondre correctement puis incorrectement,
verifier score/feedback/etat des options, passer a la question
suivante, voir l'ecran de resultat) - le script de diagnostic n'a pas
ete conserve dans le depot.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 48 tests cibles (document + onboarding) verifies frais.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 12:08:03 +02:00

142 lines
5.8 KiB
Python

"""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."""
from typing import Any
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
def _quiz_element(attributes: dict[str, Any]) -> dict[str, Any]:
return {"id": 1, "kind": "quiz", "parent_id": None, "order_index": 0, "attributes": attributes}
def test_render_quiz_without_questions_shows_only_the_badge() -> None:
html = doc_engine.render_document_element(_quiz_element(doc_engine.DEFAULT_QUIZ_CONFIG), {})
assert "0 question" in html
assert "docQuizPlayer" not in html
def test_render_quiz_with_questions_includes_the_interactive_player() -> None:
config = doc_engine.sanitize_quiz_config(
{"questions": [{"text": "Vrai ou faux ?", "choices": ["Vrai", "Faux"], "correct_index": 0, "points": 10}]}
)
html = doc_engine.render_document_element(_quiz_element(config), {})
assert "docQuizPlayer" in html
assert "docQuizOptions" in html
assert "data-quiz-config=" in html
assert "Vrai ou faux ?" in html
def test_render_quiz_escapes_question_text_in_embedded_json() -> None:
"""Le JSON des questions est embarqué dans un attribut HTML (jamais un
<script> par élément) — html.escape(quote=True) doit neutraliser toute
tentative d'injection via le texte d'une question/d'un choix (champ
libre du créateur)."""
config = doc_engine.sanitize_quiz_config(
{
"questions": [
{"text": '"><script>alert(1)</script>', "choices": ["A", "B"], "correct_index": 0, "points": 1},
]
}
)
html = doc_engine.render_document_element(_quiz_element(config), {})
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html