Quiz reellement interactif en Mode Apercu (voir maquette document-formation-web.html)
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

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>
This commit is contained in:
william
2026-09-20 12:08:03 +02:00
co-authored by Claude Sonnet 5
parent 9d87a4bfef
commit 9fb22be1ae
5 changed files with 464 additions and 5 deletions
@@ -1,4 +1,5 @@
import html as html_lib
import json
from typing import Any
_SHAPE_TAGS = {"rectangle": "rect", "cercle": "circle", "trait": "line"}
@@ -130,9 +131,42 @@ def _render_minigame_placeholder(
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="{el["kind"]}" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">{label}</span>'
f'<span class="docMinigamePlaceholder">Formulaire de contenu à venir</span>'
f"</div></div>"
)
def _render_quiz_player(config: dict[str, Any]) -> str:
"""Questionnaire RÉELLEMENT interactif — affiché uniquement en Mode
Aperçu (voir static/document/document-editor.css,
.docEditor3--preview), pour que le créateur puisse tester son quiz
avant un futur export (voir docs/plan/maquettes/
document-formation-web.html, référence visuelle de ce rendu). Les
questions sont embarquées en JSON dans un attribut data-* (jamais un
<script> par élément) — static/document/js/document-editor.js les lit
et gère tout le déroulé (réponse/score/question suivante/résultat)
côté client, sans aucun aller-retour serveur (une session d'Aperçu
n'est jamais persistée)."""
config_json = html_lib.escape(json.dumps({"questions": config["questions"]}), quote=True)
return (
f'<div class="docQuizPlayer" data-quiz-config="{config_json}">'
f'<div class="docQuizCard">'
f'<div class="docQuizKicker">Quiz</div>'
f'<div class="docQuizPlayerTitle">Vérifiez vos connaissances</div>'
f'<div class="docQuizProgressTrack"><div class="docQuizProgressFill"></div></div>'
f'<div class="docQuizMeta"><span class="docQuizProg"></span><span class="docQuizScore"></span></div>'
f'<div class="docQuizQuestionText"></div>'
f'<div class="docQuizOptions"></div>'
f'<div class="docQuizFeedback"></div>'
f'<div class="docQuizNextBar"><button type="button" class="docQuizNextBtn">Question suivante</button></div>'
f"</div>"
f'<div class="docQuizResultCard" style="display:none;">'
f'<div class="docQuizResultBig"></div>'
f'<div class="docQuizResultSub"></div>'
f'<button type="button" class="docQuizRestartBtn">Recommencer le quiz</button>'
f"</div></div>"
)
@@ -146,12 +180,16 @@ def _render_quiz(el: dict[str, Any], _children_by_parent: dict[int | None, list[
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}"
player_html = _render_quiz_player(config) if config["questions"] else ""
return (
f'<div class="docMinigame" data-element-id="{el["id"]}" data-kind="quiz" '
f'style="border-color:{theme_color};">'
f'<div class="docMinigameBadge">'
f'<span class="docMinigameLabel">Quiz</span>'
f'<span class="docMinigamePlaceholder">{html_lib.escape(subtitle)}</span>'
f"</div>"
f"{player_html}"
f"</div>"
)
+13 -4
View File
@@ -35,10 +35,19 @@ 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.
- **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.
- **Quiz** : toujours une 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. Si au moins une question existe,
s'y ajoute (fonction privée `_render_quiz_player`) le questionnaire
RÉEL et interactif affiché en Mode Aperçu (voir docs/plan/maquettes/
document-formation-web.html — référence visuelle), masqué en édition
par CSS (`.docQuizPlayer`, voir static/document/document-editor.css) :
les questions sont embarquées en JSON dans un attribut `data-quiz-config`
(jamais un `<script>` par élément), échappé pour l'HTML
(`html.escape(..., quote=True)`) — static/document/js/document-editor.js
lit cet attribut et gère tout le déroulé (réponse/score/question
suivante/résultat) côté client, sans aucun aller-retour serveur.
- **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é,
+247 -1
View File
@@ -31,6 +31,12 @@ body.objectEditBody > main.content {
--doc-accent: var(--accent);
--doc-accent-2: var(--accent-2);
--doc-glow: 0 0 0 1px rgb(255 95 46 / 25%), 0 8px 24px -6px rgb(255 95 46 / 25%);
--doc-quiz-success-bg: rgb(59 165 93 / 12%);
--doc-quiz-success-border: #3ba55d;
--doc-quiz-success-text: #8fe3ab;
--doc-quiz-danger-bg: rgb(255 95 46 / 12%);
--doc-quiz-danger-border: #ff5f2e;
--doc-quiz-danger-text: #ffb3a0;
/* Remplit main.content (voir body.objectEditBody dans static/style.css :
`main.content` y est déjà `flex:1 1 auto; min-height:0; overflow:hidden;
@@ -58,6 +64,12 @@ body.objectEditBody > main.content {
--doc-text: #14161c;
--doc-muted: #6b7280;
--doc-glow: 0 0 0 1px rgb(255 95 46 / 30%), 0 8px 24px -6px rgb(255 95 46 / 30%);
--doc-quiz-success-bg: #e4f5ea;
--doc-quiz-success-border: #2e7d46;
--doc-quiz-success-text: #1d4c2c;
--doc-quiz-danger-bg: #fdede8;
--doc-quiz-danger-border: #c1451f;
--doc-quiz-danger-text: #7a1418;
}
.docEditor3 * {
@@ -467,12 +479,15 @@ img.docImage {
.docMinigame {
border: 1px solid var(--doc-accent);
border-radius: 12px;
background: rgb(255 95 46 / 8%);
}
.docMinigameBadge {
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: rgb(255 95 46 / 8%);
}
.docMinigameLabel {
@@ -485,6 +500,237 @@ img.docImage {
color: var(--doc-muted);
}
/* ---- Quiz : questionnaire interactif en Mode Aperçu (voir docs/plan/
maquettes/document-formation-web.html — référence visuelle). Masqué en
édition (seul .docMinigameBadge y est visible, voir plus haut) ; les
deux jamais visibles en même temps. ---- */
.docQuizPlayer {
display: none;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] .docMinigameBadge {
display: none;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] .docQuizPlayer {
display: block;
}
.docEditor3.docEditor3--preview [data-kind="quiz"] {
border: none;
background: transparent;
}
.docEditor3.docEditor3--preview .docQuizPlayer,
.docEditor3.docEditor3--preview .docQuizPlayer * {
/* .docEditor3--preview désactive pointer-events sur tout [data-element-id]
(voir plus bas) pour empêcher la sélection/le déplacement pendant
l'Aperçu — réactivé ici spécifiquement pour que le quiz reste
RÉELLEMENT cliquable (répondre, question suivante, recommencer). */
pointer-events: auto;
}
.docQuizCard,
.docQuizResultCard {
background: linear-gradient(180deg, var(--doc-card), var(--doc-bg-2));
border: 1px solid var(--doc-border);
border-radius: 16px;
padding: 1.8rem;
}
.docQuizResultCard {
text-align: center;
}
.docQuizKicker {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: var(--doc-accent);
margin-bottom: 6px;
}
.docQuizPlayerTitle {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 20px;
}
.docQuizProgressTrack {
height: 4px;
background: var(--doc-border);
border-radius: 2px;
overflow: hidden;
margin-bottom: 10px;
}
.docQuizProgressFill {
height: 100%;
width: 0%;
background: linear-gradient(120deg, var(--doc-accent), var(--doc-accent-2));
transition: width 0.2s ease;
}
.docQuizMeta {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--doc-muted);
font-weight: 600;
margin-bottom: 20px;
}
.docQuizQuestionText {
font-size: 17px;
font-weight: 700;
line-height: 1.3;
margin: 0 0 18px;
}
.docQuizOptions {
display: flex;
flex-direction: column;
gap: 10px;
}
.docQuizOption {
text-align: left;
padding: 13px 15px;
border: 1px solid var(--doc-border);
background: var(--doc-bg-2);
color: var(--doc-text);
border-radius: 10px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
transition: border-color 0.15s ease;
}
.docQuizOption:hover:not(:disabled) {
border-color: var(--doc-accent);
}
.docQuizOptionLetter {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid var(--doc-border);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12.5px;
color: var(--doc-muted);
}
.docQuizOption.is-correct {
border-color: var(--doc-quiz-success-border);
background: var(--doc-quiz-success-bg);
}
.docQuizOption.is-correct .docQuizOptionLetter {
border-color: var(--doc-quiz-success-border);
background: var(--doc-quiz-success-border);
color: #0b0e14;
}
.docQuizOption.is-wrong {
border-color: var(--doc-quiz-danger-border);
background: var(--doc-quiz-danger-bg);
}
.docQuizOption.is-wrong .docQuizOptionLetter {
border-color: var(--doc-accent);
background: var(--doc-accent);
color: #0b0e14;
}
.docQuizOption:disabled:not(.is-correct, .is-wrong) {
opacity: 0.45;
}
.docQuizFeedback {
display: none;
margin-top: 16px;
padding: 13px 15px;
border-radius: 10px;
font-size: 14px;
line-height: 1.5;
}
.docQuizFeedback.is-visible {
display: block;
}
.docQuizFeedback.is-ok {
background: var(--doc-quiz-success-bg);
color: var(--doc-quiz-success-text);
border: 1px solid var(--doc-quiz-success-border);
}
.docQuizFeedback.is-ko {
background: var(--doc-quiz-danger-bg);
color: var(--doc-quiz-danger-text);
border: 1px solid var(--doc-quiz-danger-border);
}
.docQuizNextBar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
.docQuizNextBtn {
padding: 0.75rem 1.6rem;
border-radius: 10px;
border: none;
background: linear-gradient(120deg, var(--doc-accent), var(--doc-accent-2));
color: #14100c;
font-weight: 700;
font-size: 14px;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.docQuizNextBtn.is-visible {
opacity: 1;
pointer-events: auto;
}
.docQuizResultBig {
font-weight: 800;
font-size: 44px;
background: linear-gradient(120deg, var(--doc-accent), var(--doc-accent-2));
background-clip: text;
color: transparent;
line-height: 1;
}
.docQuizResultSub {
margin-top: 6px;
margin-bottom: 18px;
font-size: 13px;
color: var(--doc-muted);
font-weight: 600;
}
.docQuizRestartBtn {
padding: 0.7rem 1.5rem;
border-radius: 10px;
background: transparent;
border: 1px solid var(--doc-border);
color: var(--doc-text);
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.docUnknown {
font-size: 12.5px;
color: var(--doc-muted);
+126
View File
@@ -475,6 +475,132 @@ function forgeDocBindCanvasInteractions() {
});
}
});
forgeDocInitQuizPlayers();
}
/* ---------------------------------------------------------------------
* Quiz — questionnaire RÉELLEMENT interactif en Mode Aperçu (voir
* docs/plan/maquettes/document-formation-web.html, référence visuelle).
* État tenu en mémoire (jamais persisté — une session d'Aperçu se
* réinitialise à chaque rafraîchissement du canevas, exactement comme le
* reste de l'éditeur).
* ------------------------------------------------------------------- */
function forgeDocQuizPlayerQuestions(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.quizConfig || '{}');
return Array.isArray(config.questions) ? config.questions : [];
} catch (e) {
return [];
}
}
function forgeDocRenderQuizPlayerQuestion(playerEl, state) {
state.answered = false;
const item = state.questions[state.current];
const textEl = playerEl.querySelector('.docQuizQuestionText');
const optionsEl = playerEl.querySelector('.docQuizOptions');
const feedbackEl = playerEl.querySelector('.docQuizFeedback');
const nextBtn = playerEl.querySelector('.docQuizNextBtn');
const fillEl = playerEl.querySelector('.docQuizProgressFill');
const progEl = playerEl.querySelector('.docQuizProg');
const scoreEl = playerEl.querySelector('.docQuizScore');
const letters = ['A', 'B', 'C', 'D'];
textEl.textContent = item.text;
optionsEl.innerHTML = '';
feedbackEl.className = 'docQuizFeedback';
feedbackEl.textContent = '';
nextBtn.classList.remove('is-visible');
item.choices.forEach((choice, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'docQuizOption';
btn.innerHTML = `<span class="docQuizOptionLetter">${letters[idx]}</span><span></span>`;
btn.querySelector('span:last-child').textContent = choice;
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocQuizPlayerAnswer(playerEl, state, idx, btn);
});
optionsEl.appendChild(btn);
});
fillEl.style.width = `${(state.current / state.questions.length) * 100}%`;
progEl.textContent = `Question ${state.current + 1} sur ${state.questions.length}`;
scoreEl.textContent = `Score : ${state.score}`;
}
function forgeDocQuizPlayerAnswer(playerEl, state, idx, btn) {
if (state.answered) return;
state.answered = true;
const item = state.questions[state.current];
const options = playerEl.querySelectorAll('.docQuizOption');
options.forEach((o) => { o.disabled = true; });
const feedbackEl = playerEl.querySelector('.docQuizFeedback');
const nextBtn = playerEl.querySelector('.docQuizNextBtn');
const scoreEl = playerEl.querySelector('.docQuizScore');
if (idx === item.correct_index) {
btn.classList.add('is-correct');
state.score += item.points || 0;
feedbackEl.classList.add('is-visible', 'is-ok');
feedbackEl.textContent = 'Bonne réponse !';
} else {
btn.classList.add('is-wrong');
options[item.correct_index].classList.add('is-correct');
feedbackEl.classList.add('is-visible', 'is-ko');
feedbackEl.textContent = 'Mauvaise réponse.';
}
scoreEl.textContent = `Score : ${state.score}`;
nextBtn.classList.add('is-visible');
nextBtn.textContent = state.current === state.questions.length - 1 ? 'Voir le résultat' : 'Question suivante';
}
function forgeDocQuizPlayerTotalPoints(state) {
return state.questions.reduce((sum, q) => sum + (q.points || 0), 0);
}
function forgeDocShowQuizPlayerResult(playerEl, state) {
playerEl.querySelector('.docQuizCard').style.display = 'none';
const resultCard = playerEl.querySelector('.docQuizResultCard');
resultCard.style.display = 'block';
playerEl.querySelector('.docQuizProgressFill').style.width = '100%';
playerEl.querySelector('.docQuizResultBig').textContent = `${state.score} / ${forgeDocQuizPlayerTotalPoints(state)}`;
playerEl.querySelector('.docQuizResultSub').textContent = 'points obtenus';
}
function forgeDocQuizPlayerNext(playerEl, state) {
state.current += 1;
if (state.current < state.questions.length) {
forgeDocRenderQuizPlayerQuestion(playerEl, state);
} else {
forgeDocShowQuizPlayerResult(playerEl, state);
}
}
function forgeDocQuizPlayerRestart(playerEl, state) {
state.current = 0;
state.score = 0;
playerEl.querySelector('.docQuizResultCard').style.display = 'none';
playerEl.querySelector('.docQuizCard').style.display = 'block';
forgeDocRenderQuizPlayerQuestion(playerEl, state);
}
function forgeDocInitQuizPlayers() {
document.querySelectorAll('.docQuizPlayer').forEach((playerEl) => {
const questions = forgeDocQuizPlayerQuestions(playerEl);
if (!questions.length) return;
const state = {
current: 0, score: 0, answered: false, questions,
};
forgeDocRenderQuizPlayerQuestion(playerEl, state);
playerEl.querySelector('.docQuizNextBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocQuizPlayerNext(playerEl, state);
});
playerEl.querySelector('.docQuizRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocQuizPlayerRestart(playerEl, state);
});
});
}
function forgeDocSetActiveBottomNav(target) {
+40
View File
@@ -1,6 +1,8 @@
"""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
@@ -99,3 +101,41 @@ def test_quiz_total_points_sums_every_question() -> None:
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