Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+223 -97
View File
@@ -5,11 +5,18 @@ quiz_box_config.py, screens/rendering/quiz_box_templates.py. DISTINCT de
"🎨 Style" (dialogue_box_style.py, partagé avec dialogue_box/score_widget,
voir test_dialogue_box.py) : ce fichier ne teste que plein écran/
minuteur/modèle."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_quiz_config"):
def _create_jeu2d_game(tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_quiz_config") -> Any:
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
@@ -17,12 +24,15 @@ def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_quiz_config"):
# ---------- Sanitizer/résolution ----------
def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_template(tmp_game_slug_cleanup):
""""Aucun" minuteur/plein écran désactivé par défaut — demande
def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_template(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
""" "Aucun" minuteur/plein écran désactivé par défaut — demande
explicite : jamais imposé, seulement au choix du créateur."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
config = screens.resolve_quiz_box_config(obj)
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
assert config["fullscreen"] is False
@@ -31,27 +41,39 @@ def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_templ
assert config["page_template"] == "classique"
def test_sanitize_quiz_box_config_clamps_timer_seconds(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_clamps_timer_seconds(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 999999})
config = not_none(screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 999999}))
assert config["timer_seconds"] == 600
config = screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 0})
config = not_none(screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 0}))
assert config["timer_seconds"] == 3
def test_sanitize_quiz_box_config_rejects_unknown_timer_mode_and_templates(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_rejects_unknown_timer_mode_and_templates(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {
"timer_mode": "toutes-les-secondes", "dialog_template": "arc-en-ciel", "page_template": "aquarelle",
})
config = not_none(
screens.set_scene_object_quiz_config(
slug,
obj_id,
{
"timer_mode": "toutes-les-secondes",
"dialog_template": "arc-en-ciel",
"page_template": "aquarelle",
},
)
)
assert config["timer_mode"] == "aucun"
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "classique"
def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : les deux réglages sont conservés
INDÉPENDAMMENT — un modèle "boîte" invalide ne doit jamais affecter le
modèle "page" valide, et réciproquement ("verre" n'existe plus depuis
@@ -60,46 +82,65 @@ def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(tmp
page_template valide à côté)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "verre", "page_template": "manga"})
config = not_none(
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "verre", "page_template": "manga"})
)
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "manga"
def test_sanitize_quiz_box_config_ignores_a_non_dict_input(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_ignores_a_non_dict_input(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, "pas-un-dict")
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
def test_set_scene_object_quiz_config_persists_and_sanitizes(tmp_game_slug_cleanup):
def test_set_scene_object_quiz_config_persists_and_sanitizes(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug,
obj_id,
{
"fullscreen": True,
"timer_mode": "question",
"timer_seconds": 15,
"dialog_template": "defaut",
"page_template": "manga",
},
)
obj = not_none(screens.get_scene_object(slug, obj_id))
config = screens.resolve_quiz_box_config(obj)
assert config == {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
"fullscreen": True,
"timer_mode": "question",
"timer_seconds": 15,
"dialog_template": "defaut",
"page_template": "manga",
}
def test_set_scene_object_quiz_config_returns_none_for_unknown_object(tmp_game_slug_cleanup):
def test_set_scene_object_quiz_config_returns_none_for_unknown_object(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, _ = _create_jeu2d_game(tmp_game_slug_cleanup)
assert screens.set_scene_object_quiz_config(slug, 999999, {}) is None
# ---------- Route ----------
def test_quiz_config_route_persists_config_and_returns_rendered_html(client, tmp_game_slug_cleanup):
def test_quiz_config_route_persists_config_and_returns_rendered_html(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/quiz-config",
data='{"config": {"fullscreen": false, "timer_mode": "quiz", "timer_seconds": 30, "dialog_template": "defaut"}}',
data=(
'{"config": {"fullscreen": false, "timer_mode": "quiz", "timer_seconds": 30, "dialog_template": "defaut"}}'
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -107,11 +148,13 @@ def test_quiz_config_route_persists_config_and_returns_rendered_html(client, tmp
assert body["config"]["fullscreen"] is False
assert body["config"]["dialog_template"] == "defaut"
assert 'class="quizBoxWidget"' in body["rendered_html"]
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_quiz_box_config(obj)["dialog_template"] == "defaut"
def test_quiz_config_route_page_template_only_applies_when_fullscreen(client, tmp_game_slug_cleanup):
def test_quiz_config_route_page_template_only_applies_when_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
@@ -125,8 +168,10 @@ def test_quiz_config_route_page_template_only_applies_when_fullscreen(client, tm
assert "quizBoxWidget--manga" in body["rendered_html"]
def test_quiz_config_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/quiz-config", data='{"config": {}}', content_type="application/json")
def test_quiz_config_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/scene-objects/999999/quiz-config", data='{"config": {}}', content_type="application/json"
)
assert resp.status_code == 404
@@ -135,12 +180,15 @@ def test_quiz_config_route_404s_for_missing_object(client, game):
# présentation, pour que le moteur JS partagé fonctionne quel que soit le
# modèle choisi ----------
def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(tmp_game_slug_cleanup):
def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Non-régression explicite : un quiz existant (jamais reconfiguré)
doit rester visuellement identique à avant cette extension."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "quizBoxWidget--" not in html
assert 'data-quiz-role="header"' in html
@@ -151,7 +199,9 @@ def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(tmp_g
assert "quizBoxFooter" not in html
def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_slug_cleanup):
def test_render_quiz_box_header_and_topbar_share_the_same_header_row(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "le score doit être à l'intérieur de la boîte
à quiz, pas à l'extérieur" — le score (topbar) et le titre (header)
doivent être frères DANS le même bandeau ("quizBoxHeaderRow"), jamais
@@ -160,7 +210,7 @@ def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_sl
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
row_start = html.index("quizBoxHeaderRow")
row_end = html.index("quizBoxBody") # le bandeau se termine avant le corps
@@ -168,7 +218,9 @@ def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_sl
assert row_start < html.index('data-quiz-role="topbar"', row_start) < row_end, template
def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors(tmp_game_slug_cleanup):
def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Contrat commun (voir §3 du plan) — vérifié ici pour les modèles
"boîte de dialogue" (fullscreen=False, voir le test jumeau pour les
modèles "page de quiz")."""
@@ -176,7 +228,7 @@ def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_ro
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
for role in ("header", "question", "choices", "topbar", "score", "timer"):
assert f'data-quiz-role="{role}"' in html, f"modèle {template!r} : ancrage {role!r} manquant"
@@ -184,7 +236,9 @@ def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_ro
assert 'class="quizBoxWidget' in html
def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role_anchors(tmp_game_slug_cleanup):
def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role_anchors(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Même contrat, pour les modèles "page de quiz" (fullscreen=True) —
"topbar" n'est PAS exigé ici (voir le contrat assoupli dans
screens/rendering/quiz_box_templates.py : un modèle "page" n'a pas
@@ -195,7 +249,7 @@ def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
for template in screens.QUIZ_BOX_PAGE_TEMPLATES:
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": template})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
for role in ("header", "question", "choices", "score", "timer"):
assert f'data-quiz-role="{role}"' in html, f"modèle {template!r} : ancrage {role!r} manquant"
@@ -203,42 +257,54 @@ def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role
assert 'class="quizBoxWidget' in html
def test_render_quiz_box_reflects_fullscreen_and_timer_data_attributes(tmp_game_slug_cleanup):
def test_render_quiz_box_reflects_fullscreen_and_timer_data_attributes(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 42})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 42}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'data-fullscreen="1"' in html
assert 'data-timer-mode="question"' in html
assert 'data-timer-seconds="42"' in html
def test_render_quiz_box_manga_shows_aucun_under_temps_when_no_timer_is_configured(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_shows_aucun_under_temps_when_no_timer_is_configured(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "garde le temps mais si il n'y a pas de chrono
marque en dessous de temps aucun" — la plaque "Temps" reste TOUJOURS
visible (jamais masquée), avec "Aucun" en son absence."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "aucun", "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "aucun", "page_template": "manga"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">Aucun</span>' in html
def test_render_quiz_box_manga_leaves_the_timer_value_empty_when_a_timer_is_configured(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_leaves_the_timer_value_empty_when_a_timer_is_configured(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Un vrai minuteur est rempli par le moteur JS (compte à rebours), pas
par le rendu serveur — jamais "Aucun" affiché en même temps qu'un
minuteur réellement configuré."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 20, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 20, "page_template": "manga"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer"></span>' in html
def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""Demande explicite : "il n'y a pas le steper" — le HTML doit exposer
un conteneur `data-manga-role="progress"` que le JS (forgeQuizMangaOnShow,
static/js/play/quiz-box-templates.js) remplit au jeu (nombre de questions
@@ -246,12 +312,14 @@ def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="mangaProgress" data-manga-role="progress"></div>' in html
def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "il faut aussi adapter le modele manga pour
avoir sa page de résultat" — voir forgeQuizMangaOnQuizComplete,
static/js/play/quiz-box-templates.js, qui remplit/affiche ce
@@ -259,13 +327,15 @@ def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(tmp_g
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="mangaResultZone" data-manga-role="result"></div>' in html
def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(tmp_game_slug_cleanup):
""""Manga" (boîte de dialogue) — demande explicite : "il faut créer un
def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
""" "Manga" (boîte de dialogue) — demande explicite : "il faut créer un
modele de boite themé mangas qui va avec le mode de page mangas",
validé via une maquette interactive. Reste une petite carte flottante
(fullscreen=False), structure PARTAGÉE avec les autres modèles
@@ -274,14 +344,16 @@ def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(tmp
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'class="quizBoxWidget quizBoxWidget--mangaDialogue"' in html
assert '<div class="quizMangaDialogueFrame">' in html
assert 'data-quiz-template="manga_dialogue"' in html
def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "le modele dois etre une copie conforme a la
maquete au pixel pret" — un bandeau de pied de page sombre reste
TOUJOURS visible (même principe que le modèle "page" "Classique"),
@@ -290,17 +362,19 @@ def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(tmp_ga
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>' in html
# "Titre de la quête" seul (jamais "Quête : Titre de la quête") — le
# préfixe "Quête : " est posé en CSS (::before), pas dans le HTML,
# sinon il apparaîtrait EN DOUBLE dans l'aperçu éditeur.
assert '>Titre de la quête<' in html
assert '>Quête : Titre de la quête<' not in html
assert ">Titre de la quête<" in html
assert ">Quête : Titre de la quête<" not in html
def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite (répétée : "il est ou le bouton continuer !!!!!",
puis "le bouton continuer est toujours visible mais activer seulement
quand un choix est fait") — le vrai bouton, comme ce repère d'aperçu
@@ -310,12 +384,14 @@ def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeho
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>' in html
def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_continue_button(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_continue_button(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "le contener qui contien les choix a les bord
du bas qui ne sont pas pointue" — le coin dentelé du bas est un
simple `background` (dégradé) sur .quizBoxBody, PAS un clip-path ni
@@ -327,7 +403,7 @@ def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_con
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "quizMangaDialogueBodyBg" not in html
assert (
@@ -335,26 +411,28 @@ def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_con
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
'</div>'
"</div>"
) in html
def test_render_quiz_box_classique_is_the_default_page_template(tmp_game_slug_cleanup):
""""Classique" — modèle de PAGE DE QUIZ DE BASE, devient le réglage
def test_render_quiz_box_classique_is_the_default_page_template(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
""" "Classique" — modèle de PAGE DE QUIZ DE BASE, devient le réglage
par défaut (au même titre que "defaut"/Classique côté "boîte de
dialogue") : un créateur qui active juste "Plein écran" sans jamais
toucher aux modèles obtient ce modèle sobre, jamais "manga"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
config = screens.resolve_quiz_box_config(obj)
assert config["page_template"] == "classique"
html = screens.render_scene_object(obj)
assert 'class="quizBoxWidget quizBoxWidget--classique"' in html
def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "aujourd'hui un quiz n'a pas de titre mais il y
a un champs nom qui peut servir de titre pour le modele" — pas de
nouveau champ, le champ "Nom" (déjà existant, "ℹ️ Informations")
@@ -363,30 +441,38 @@ def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(tmp_g
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_name(slug, obj_id, "Formation Sécurité Incendie")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="classiqueHeaderTitle">Formation Sécurité Incendie</span>' in html
def test_render_quiz_box_classique_falls_back_to_a_generic_title_without_a_name(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_falls_back_to_a_generic_title_without_a_name(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="classiqueHeaderTitle">Quiz</span>' in html
def test_render_quiz_box_classique_shows_aucun_under_temps_when_no_timer_is_configured(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_shows_aucun_under_temps_when_no_timer_is_configured(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">Aucun</span>' in html
def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pill_container(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pill_container(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite (répétée) : "le conteneur de temps + aucun
doivent avoir la background mais pas le mot aucun" — UN SEUL fond
(celui de ".classiqueChip", identique pour "Temps" et "Score", avec
@@ -397,15 +483,19 @@ def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pi
autour du mot "Aucun", jamais autour de "Score : 0"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'class="classiqueChip classiqueChip--timer"' in html
assert 'class="classiqueChip classiqueChip--score"' in html
assert 'class="classiqueChipValue quizBoxTimerDisplay"' in html
def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Écran de résultat de fin de quiz (demande explicite : "elle affiche
un conteneur qui reaffiche le contenu du quiz au lieu du resultat" —
fonctionnalité désormais réellement implémentée, voir
@@ -415,12 +505,14 @@ def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(t
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueResultZone" data-classique-role="result"></div>' in html
def test_render_quiz_box_classique_has_an_always_visible_footer_band(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_has_an_always_visible_footer_band(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "il manque le footer foncé avec le bouton" —
un bandeau de pied de page (couleur footer_bg du panneau "🎨 Style")
reste TOUJOURS visible, même avant toute réponse (le bouton
@@ -429,21 +521,25 @@ def test_render_quiz_box_classique_has_an_always_visible_footer_band(tmp_game_sl
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueFooterBand" aria-hidden="true"></div>' in html
def test_render_quiz_box_classique_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_has_a_progress_stepper_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueProgress" data-classique-role="progress"></div>' in html
def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Encart d'explication réservé pour une fonctionnalité FUTURE non
encore implémentée (demande explicite : "tu peut le mettre dans le
modele mais ne fonctionnera pas pour le moment") — présent dans le
@@ -451,12 +547,14 @@ def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(tmp
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueExplain" data-quiz-role="explain"></div>' in html
def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(tmp_game_slug_cleanup):
def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Le panneau "🎨 Style" (couleurs/police) continue de s'appliquer à
TOUS les modèles — un modèle change la disposition/décoration, jamais
une raison d'ignorer les couleurs déjà choisies par le créateur."""
@@ -464,14 +562,15 @@ def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(tmp_game
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_dialogue_box_style(slug, obj_id, {"text_color": "#123456"})
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "defaut"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "#123456" in html
# ---------- Payload ----------
def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanup):
def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
@@ -482,7 +581,9 @@ def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanu
assert box["quiz_config"]["page_template"] == "manga"
def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(tmp_game_slug_cleanup):
def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Bug signalé : "il faut régler la caméra sur plein écran et
désactiver ce réglage quand la boîte à quiz est plein écran" — ce
drapeau permet à templates/play.html de rendre l'ÉCRAN lui-même en
@@ -500,7 +601,9 @@ def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(tmp_game_s
assert scene["any_quiz_box_fullscreen"] is True
def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
@@ -511,7 +614,9 @@ def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_full
assert "width:960px" not in html
def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(client, tmp_game_slug_cleanup):
def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
@@ -524,7 +629,10 @@ def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(client,
# ---------- Éditeur — panneau propriétés (rendu, pas de logique JS ici) ----------
def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
@@ -537,7 +645,9 @@ def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(cli
assert f"selectQuizBoxDialogTemplate({obj_id}, '{template}')" in html
def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : les modèles "page de quiz" ne se voient QUE
quand la boîte à quiz est plein écran — jamais les deux galeries en
même temps."""
@@ -551,7 +661,9 @@ def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(client, t
assert f"selectQuizBoxPageTemplate({obj_id}, '{template}')" in html
def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(client, tmp_game_slug_cleanup):
def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
@@ -564,7 +676,10 @@ def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(cli
# occuper tout l'écran", "être visible par défaut", "la notion de caméra
# disparaît" — pas seulement au jeu) ----------
def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(client, tmp_game_slug_cleanup):
def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
@@ -574,11 +689,14 @@ def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(clie
# .canvasElement.isQuizFullscreenPreview) doit être posée sur le
# conteneur de CET objet précis.
import re
m = re.search(r'<div class="canvasElement[^"]*"\s+id="el-' + str(obj_id) + '"', html)
assert m and "isQuizFullscreenPreview" in m.group(0)
def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug signalé : "il ne devait plus y avoir de notion de caméra" —
masquer seulement le cadre "🎥 Champ de la caméra" ne suffisait pas,
le CANEVAS restait lui-même dimensionné en pixels fixes
@@ -589,6 +707,7 @@ def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullsc
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
import re
m = re.search(r'<div class="canvas" id="canvas" style="([^"]*)"', html)
assert m
style = m.group(1)
@@ -596,17 +715,22 @@ def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullsc
assert "px" not in style.split(";")[0] and "px" not in style.split(";")[1]
def test_scene_edit_view_canvas_keeps_pixel_size_without_a_fullscreen_quiz_box(client, tmp_game_slug_cleanup):
def test_scene_edit_view_canvas_keeps_pixel_size_without_a_fullscreen_quiz_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
import re
m = re.search(r'<div class="canvas" id="canvas" style="([^"]*)"', html)
assert m
assert "width:960px; height:540px;" in m.group(1)
def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
# Un fond plus grand que la scène nominale, pour que "🎥 Champ de la
# caméra" soit normalement affiché (voir resolve_scene_world_size).
@@ -621,7 +745,9 @@ def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(cl
assert "sceneCameraFrame" not in html_after
def test_scene_edit_view_keeps_the_camera_frame_when_no_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_keeps_the_camera_frame_when_no_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=2000, height=2000)