Files
Forge-Engine/tests/game/test_quiz_box_config.py
T
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

757 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Réglages "❓ Boîte à quiz" propres au quiz AUTONOME/plein écran (demande
explicite : "étendre les fonctionnalités de la boîte à quiz pour les RH
qui veulent juste faire un quiz") — voir game_engine/rendering/
quiz_box_config.py, game_engine/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 game_engine
from tests.conftest import not_none
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 = game_engine.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
# ---------- Sanitizer/résolution ----------
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
obj = not_none(game_engine.get_scene_object(slug, obj_id))
config = game_engine.resolve_quiz_box_config(obj)
assert config == game_engine.DEFAULT_QUIZ_BOX_CONFIG
assert config["fullscreen"] is False
assert config["timer_mode"] == "aucun"
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "classique"
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
config = not_none(game_engine.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 999999}))
assert config["timer_seconds"] == 600
config = not_none(game_engine.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: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
config = not_none(
game_engine.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: 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
la suppression des modèles "boîte" non-base, sert ici justement à
vérifier qu'une valeur invalide retombe sur "defaut" SANS toucher au
page_template valide à côté)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
config = not_none(
game_engine.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: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
config = game_engine.set_scene_object_quiz_config(slug, obj_id, "pas-un-dict")
assert config == game_engine.DEFAULT_QUIZ_BOX_CONFIG
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.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(game_engine.get_scene_object(slug, obj_id))
config = game_engine.resolve_quiz_box_config(obj)
assert config == {
"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: Callable[[str], str],
) -> None:
slug, _ = _create_jeu2d_game(tmp_game_slug_cleanup)
assert game_engine.set_scene_object_quiz_config(slug, 999999, {}) is None
# ---------- Route ----------
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 = game_engine.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"}}'
),
content_type="application/json",
)
assert resp.status_code == 200
body = resp.get_json()
assert body["config"]["fullscreen"] is False
assert body["config"]["dialog_template"] == "defaut"
assert 'class="quizBoxWidget"' in body["rendered_html"]
obj = not_none(game_engine.get_scene_object(slug, obj_id))
assert game_engine.resolve_quiz_box_config(obj)["dialog_template"] == "defaut"
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/quiz-config",
data='{"config": {"fullscreen": true, "page_template": "manga"}}',
content_type="application/json",
)
assert resp.status_code == 200
body = resp.get_json()
assert body["config"]["page_template"] == "manga"
assert "quizBoxWidget--manga" in body["rendered_html"]
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
# ---------- Rendu — "contrat" de points d'ancrage (voir §3 du plan) :
# chaque modèle doit exposer les mêmes data-quiz-role, quelle que soit sa
# 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: 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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.render_scene_object(obj)
assert "quizBoxWidget--" not in html
assert 'data-quiz-role="header"' in html
assert 'data-quiz-role="question"' in html
assert 'data-quiz-role="choices"' in html
assert "Quête : Titre de la quête" in html
assert html.count("quizBoxChoiceBtn") == 2
assert "quizBoxFooter" not in html
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
deux bandes empilées de couleurs différentes."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
for template in game_engine.QUIZ_BOX_DIALOG_TEMPLATES:
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.render_scene_object(obj)
row_start = html.index("quizBoxHeaderRow")
row_end = html.index("quizBoxBody") # le bandeau se termine avant le corps
assert row_start < html.index('data-quiz-role="header"', row_start) < row_end, template
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: 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")."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
for template in game_engine.QUIZ_BOX_DIALOG_TEMPLATES:
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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"
assert f'data-quiz-template="{template}"' in html
assert 'class="quizBoxWidget' in html
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
game_engine/rendering/quiz_box_templates.py : un modèle "page" n'a pas
besoin de regrouper score/minuteur à masquer, il n'existe QUE plein
écran)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
for template in game_engine.QUIZ_BOX_PAGE_TEMPLATES:
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": template})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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"
assert f'data-quiz-template="{template}"' in html
assert 'class="quizBoxWidget' in html
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 42}
)
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "aucun", "page_template": "manga"}
)
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 20, "page_template": "manga"}
)
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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/game/js/play/quiz-box-templates.js) remplit au jeu (nombre de questions
inconnu à la création de l'objet)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: Callable[[str], str],
) -> None:
"""Demande explicite : "il faut aussi adapter le modele manga pour
avoir sa page de résultat" — voir forgeQuizMangaOnQuizComplete,
static/game/js/play/quiz-box-templates.js, qui remplit/affiche ce
conteneur EN JEU seulement (toujours vide côté rendu serveur)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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
"boîte" (contrat déjà vérifié génériquement, voir
test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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"),
même avant toute réponse (le bouton "Continuer →" du moteur partagé,
lui, n'apparaît qu'après coup, posé par-dessus ce bandeau en CSS)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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
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
éditeur, est maintenant TOUJOURS présent dès la question (posé
désactivé par forgeShowQuizBox, activé par forgeQuizBoxAnswer une
fois répondu — voir static/game/js/play/dialogue-box-controller.js)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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
un décor séparé à part (deux essais précédents, tous deux abandonnés :
l'un rognait, l'autre emprisonnait dans un contexte d'empilement, le
bouton "Continuer →" du moteur JS partagé — voir static/style.css).
Le HTML reste donc le plus simple possible : le bouton reste un
DESCENDANT DIRECT de .quizBoxChoices, sans wrapper intermédiaire."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.render_scene_object(obj)
assert "quizMangaDialogueBodyBg" not in html
assert (
'<div class="quizBoxChoices" data-quiz-role="choices">'
'<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>"
) in html
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
config = game_engine.resolve_quiz_box_config(obj)
assert config["page_template"] == "classique"
html = game_engine.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: 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")
devient le titre affiché dans le bandeau de ce modèle."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_name(slug, obj_id, "Formation Sécurité Incendie")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"}
)
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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
ou sans minuteur configuré) ; la pastille parasite que
".quizBoxTimerDisplay" pose d'ordinaire autour de sa propre valeur
(voir static/style.css) est neutralisée pour ce modèle — un bug de
classe partagée qui collait un second fond/coin arrondi UNIQUEMENT
autour du mot "Aucun", jamais autour de "Score : 0"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"}
)
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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
forgeQuizClassiqueOnQuizComplete/forgeQuizResultRestart, static/js/
play/quiz-box-templates.js et dialogue-box-controller.js) : le HTML
expose toujours ce conteneur vide, rempli/affiché EN JEU seulement."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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
"Continuer →" du moteur partagé, lui, n'apparaît qu'après coup, posé
par-dessus ce bandeau en CSS)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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
HTML mais toujours vide aujourd'hui, aucune donnée ne l'alimente."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.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: 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."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_dialogue_box_style(slug, obj_id, {"text_color": "#123456"})
game_engine.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "defaut"})
obj = not_none(game_engine.get_scene_object(slug, obj_id))
html = game_engine.render_scene_object(obj)
assert "#123456" in html
# ---------- Payload ----------
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
payload = game_engine.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
box = next(o for o in scene["elements"] if o["kind"] == "quiz_box")
assert box["quiz_config"]["fullscreen"] is True
assert box["quiz_config"]["page_template"] == "manga"
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/game/play.html de rendre l'ÉCRAN lui-même en
plein viewport (voir .playScene--quizFullscreen), pas seulement le
widget."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
payload = game_engine.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
assert scene["any_quiz_box_fullscreen"] is False
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
payload = game_engine.full_game_payload(slug)
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
assert scene["any_quiz_box_fullscreen"] is True
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 = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
assert "playScene--quizFullscreen" in html
# Le réglage "Caméra (px)" (scene_width/scene_height) ne doit plus du
# tout dimensionner l'écran dans ce cas.
assert "width:960px" not in html
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)
game_engine.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
# La règle CSS elle-même (toujours présente dans <style>) ne compte
# pas — seule la classe posée sur LA DIV de cet écran importe ici.
assert f'data-screen-id="{screen_id}"' in html
assert 'class="playScreen playScene "' in html
assert "width:960px; height:540px;" in html
# ---------- Éditeur — panneau propriétés (rendu, pas de logique JS ici) ----------
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 = game_engine.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)
assert 'id="quiz-fullscreen"' in html
assert 'id="quiz-timer-mode"' in html
assert 'id="quiz-timer-seconds"' in html
assert 'id="quizDialogTemplateGrid"' in html
assert 'id="quizPageTemplateGrid"' not in html
for template in game_engine.QUIZ_BOX_DIALOG_TEMPLATES:
assert f"selectQuizBoxDialogTemplate({obj_id}, '{template}')" in html
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."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert 'id="quizPageTemplateGrid"' in html
assert 'id="quizDialogTemplateGrid"' not in html
for template in game_engine.QUIZ_BOX_PAGE_TEMPLATES:
assert f"selectQuizBoxPageTemplate({obj_id}, '{template}')" in html
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 = game_engine.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)
assert 'id="quiz-fullscreen"' not in html
assert 'id="quizDialogTemplateGrid"' not in html
assert 'id="quizPageTemplateGrid"' not in html
# ---------- "Plein écran" dans L'ÉDITEUR (demande explicite : "elle doit
# 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: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.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)
assert f'id="el-{obj_id}"' in html
# La classe qui fait occuper tout le canevas (voir static/style.css
# .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: 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
(world_width/world_height, une notion de caméra/scène nominale),
laissant une bande vide visible autour de la boîte plein écran."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.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)
assert "width:100%; height:100%;" in style
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: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
game_engine.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: 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).
fond_id = game_engine.add_scene_object(slug, screen_id, kind="fond")
game_engine.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=2000, height=2000)
html_before = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "sceneCameraFrame" in html_before
quiz_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
game_engine.set_scene_object_quiz_config(slug, quiz_id, {"fullscreen": True})
html_after = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "sceneCameraFrame" not in html_after
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 = game_engine.add_scene_object(slug, screen_id, kind="fond")
game_engine.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=2000, height=2000)
game_engine.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)
assert "sceneCameraFrame" in html