Implemente le mini-jeu Quiz (questions/choix/points/timer)
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

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>
This commit is contained in:
william
2026-09-20 11:40:48 +02:00
co-authored by Claude Sonnet 5
parent 597d99a8de
commit 9d87a4bfef
11 changed files with 517 additions and 8 deletions
+16
View File
@@ -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",
]
@@ -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") —
+38 -3
View File
@@ -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.
+75
View File
@@ -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", []))
@@ -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'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="quiz" '
f'style="border-color:{theme_color};">'
f'<span class="docMinigameLabel">Quiz</span>'
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
f"</div>"
)
def _render_unknown(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
return f'<div class="docUnknown" data-element-id="{el["id"]}">Type inconnu : {html_lib.escape(el["kind"])}</div>'
@@ -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,
+5 -1
View File
@@ -35,7 +35,11 @@ regroupement à chaque appel.
taille/graisse/interligne) et `bold`/`italic`/`underline`/`align`/`color`.
- **Image** : `<img>`, ou un bloc placeholder si `src` est vide.
- **Bouton** : `<button>` avec son `label` et un `data-target` optionnel.
- **Mini-jeux** (`quiz`/`association`/`memory`/`mots`/`scenario`/`zones`) :
- **Quiz** : carte résumant la config réelle (nombre de questions, total
des points via `quiz_total_points`, minuteur si activé) — sanitizée
(`sanitize_quiz_config`) avant lecture, jamais un rendu direct
d'`attributes` brut.
- **Autres mini-jeux** (`association`/`memory`/`mots`/`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.
+14 -2
View File
@@ -12,10 +12,21 @@ from core.flask_app import app
def document_element_update(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Remplace les attributs d'un élément — un formulaire du panneau
Propriétés (voir static/document/js/document-editor.js) envoie
systématiquement l'état complet de ses champs."""
if document_engine.get_document_element(slug, element_id) is None:
systématiquement l'état complet de ses champs.
Un quiz est le seul kind revalidé côté serveur (sanitize_quiz_config,
même convention que game_engine/rendering/quiz_box_config.py côté
jeu) : sa forme (liste de questions/choix/index de bonne réponse) 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
attributes: dict[str, Any] = request.get_json(force=True) or {}
if element["kind"] == "quiz":
attributes = document_engine.sanitize_quiz_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),
@@ -27,6 +38,7 @@ def document_element_update(slug: str, element_id: int) -> Response | tuple[Resp
return jsonify(
{
"ok": True,
"attributes": element["attributes"],
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
}
)
+57
View File
@@ -682,6 +682,63 @@ img.docImage {
background: rgb(240 68 56 / 10%);
}
/* ---- Quiz : liste de questions répétable, chacune avec sa propre liste
de choix répétable (voir forgeDocRenderQuizProps, static/document/js/
document-editor.js) ---- */
.docQuizQuestionsLabel {
margin-top: 10px;
}
.docQuizQuestion {
border: 1px solid var(--doc-border);
border-radius: 10px;
padding: 12px;
margin-bottom: 10px;
background: var(--doc-card);
}
.docQuizQuestionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.docQuizRemoveBtn {
border: none;
background: transparent;
color: var(--doc-muted);
cursor: pointer;
font-size: 14px;
}
.docQuizRemoveBtn:hover {
color: var(--status-danger, #f04438);
}
.docQuizChoicesList {
display: flex;
flex-direction: column;
gap: 6px;
margin: 8px 0;
}
.docQuizChoiceRow {
display: flex;
align-items: center;
gap: 8px;
}
.docQuizChoiceRow input[type="text"] {
flex: 1;
}
.docQuizAddBtn {
width: 100%;
justify-content: center;
margin-top: 4px;
}
.docEmptyState {
font-size: 13px;
color: var(--doc-muted);
+153 -1
View File
@@ -195,7 +195,11 @@ function forgeDocUpdateAttributes(id, newAttributes) {
undo: () => forgeDocApiUpdate(id, oldAttributes),
redo: () => forgeDocApiUpdate(id, newAttributes),
});
el.attributes = newAttributes;
// Le serveur revalide/nettoie certains kinds (quiz — voir
// routes/document/document_element_update.py) : on adopte SA version,
// jamais celle envoyée telle quelle, pour ne jamais dériver de la
// vérité persistée (ex. un choix en trop silencieusement tronqué).
el.attributes = result.attributes || newAttributes;
const dom = document.querySelector(`[data-element-id="${id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
@@ -653,6 +657,153 @@ function forgeDocRenderMinigameProps(panel, el) {
});
}
function forgeDocQuizNewQuestion() {
return {
text: 'Nouvelle question', choices: ['Choix 1', 'Choix 2'], correct_index: 0, points: 10,
};
}
function forgeDocRenderQuizQuestionHtml(q, qIndex) {
const choicesHtml = q.choices.map((choice, cIndex) => `
<div class="docQuizChoiceRow">
<input type="radio"
name="docQuizCorrect${qIndex}"
class="docQuizCorrectRadio"
data-question-index="${qIndex}"
data-choice-index="${cIndex}"
${cIndex === q.correct_index ? 'checked' : ''}>
<input type="text"
class="docQuizChoiceInput"
value="${choice}"
placeholder="Choix ${cIndex + 1}"
data-question-index="${qIndex}"
data-choice-index="${cIndex}">
</div>
`).join('');
return `
<div class="docQuizQuestion" data-question-index="${qIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Question ${qIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-question-index="${qIndex}" title="Supprimer cette question">🗑</button>
</div>
<div class="docField">
<textarea class="docQuizTextInput" rows="2" data-question-index="${qIndex}" placeholder="Texte de la question">${q.text}</textarea>
</div>
<div class="docFieldRow">
<label>Nombre de choix</label>
<input type="number" class="docQuizChoiceCountInput" min="2" max="4" value="${q.choices.length}" data-question-index="${qIndex}" style="width:60px;">
</div>
<div class="docQuizChoicesList">${choicesHtml}</div>
<div class="docFieldRow">
<label>Points</label>
<input type="number" class="docQuizPointsInput" min="0" value="${q.points}" data-question-index="${qIndex}" style="width:70px;">
</div>
</div>
`;
}
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')}
<div class="docFieldRow">
<label class="docFieldLabel" for="docQuizTimerEnabled">Chronomètre</label>
<input type="checkbox" id="docQuizTimerEnabled" ${a.timer_enabled ? 'checked' : ''}>
</div>
${a.timer_enabled ? `
<div class="docFieldRow">
<label for="docQuizTimerSeconds">Secondes par question</label>
<input type="number" id="docQuizTimerSeconds" min="5" max="300" value="${a.timer_seconds}" style="width:70px;">
</div>` : ''}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docQuizColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Questions</div>
<div id="docQuizQuestionsList">${questions.length ? questions.map(forgeDocRenderQuizQuestionHtml).join('') : '<div class="docEmptyState">Aucune question — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docQuizAddQuestion">+ Ajouter une question</button>
${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');
+28
View File
@@ -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"]
+101
View File
@@ -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