Ajoute la fonctionnalite quiz autonome/plein ecran a la boite a quiz
Introduit la double categorie de modeles (boite de dialogue / page de quiz plein ecran) avec plein ecran, minuteur, score integre et ecran de resultat pour les modeles page ; ajoute les modeles "Manga" (boite et page) et "Classique" (page), pilotables aussi par l'assistant IA Ruby. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
559331f9cf
commit
7db4803b93
+27
@@ -161,6 +161,33 @@ _SYSTEM_PROMPT = (
|
||||
"de l'écran (ex. centré, ou en haut) via set_object_geometry après "
|
||||
"les avoir posés."
|
||||
"\n\n"
|
||||
"QUIZ AUTONOME (RH/formation, pas narratif) : quand le créateur décrit "
|
||||
"un besoin de quiz \"tout seul\" (pas un personnage/dialogue de jeu), "
|
||||
"utilise set_quiz_box_config sur l'objet kind=\"quiz_box\" pour régler "
|
||||
"le PLEIN ÉCRAN (fullscreen) et un MINUTEUR pour répondre (timer_mode : "
|
||||
"jamais imposé par défaut — demande TOUJOURS si le créateur en veut un "
|
||||
"avant d'en activer un, \"question\" redémarre à chaque question, "
|
||||
"\"quiz\" est un seul compte à rebours pour tout le quiz, timer_seconds "
|
||||
"sa durée)."
|
||||
"\n\n"
|
||||
"MODÈLES VISUELS — DEUX catégories bien distinctes, jamais confondues : "
|
||||
"dialog_template (modèles \"boîte de dialogue\" : \"defaut\" — Classique — "
|
||||
"ou \"manga_dialogue\", le pendant en petite carte du thème manga) ne "
|
||||
"s'affiche QUE si fullscreen=false. "
|
||||
"page_template (modèles \"page de quiz\" : \"classique\" — sobre/"
|
||||
"professionnel, couleurs reprises du modèle \"boîte de dialogue\" de "
|
||||
"base — ou \"manga\", un thème dessiné/typographié entièrement à part) "
|
||||
"ne s'affiche QUE si fullscreen=true — chacun un thème complet et "
|
||||
"autonome (police, formes, couleurs ET structure entièrement propres à "
|
||||
"ce modèle), pensé pour un quiz qui occupe tout l'écran. Les deux "
|
||||
"réglages sont conservés "
|
||||
"INDÉPENDAMMENT (jamais l'un n'écrase l'autre) : ne règle QUE celui qui "
|
||||
"correspond au mode (plein écran ou non) réellement voulu par le "
|
||||
"créateur, et demande le style souhaité s'il n'en a mentionné aucun "
|
||||
"plutôt que d'en choisir un au hasard. En plein écran, le score choisi "
|
||||
"via score_widget/reward_amount s'affiche automatiquement DANS la boîte "
|
||||
"à quiz, sans réglage supplémentaire."
|
||||
"\n\n"
|
||||
"Ton professionnel, adapté à une formation d'entreprise. Réponds "
|
||||
"toujours en français, de façon concise, en confirmant ce que tu as "
|
||||
"posé."
|
||||
|
||||
+51
@@ -40,6 +40,9 @@ _CONDITION_OPERATORS = sorted(CONDITION_OPERATOR_KEYS)
|
||||
_DATA_OPERATIONS = sorted(DATA_OPERATION_LABELS)
|
||||
_PERSONNAGE_ROLES = list(screens.PERSONNAGE_ROLES)
|
||||
_COLLISION_SHAPES = list(screens.COLLISION_SHAPES)
|
||||
_QUIZ_BOX_TIMER_MODES = list(screens.TIMER_MODES)
|
||||
_QUIZ_BOX_DIALOG_TEMPLATES = list(screens.QUIZ_BOX_DIALOG_TEMPLATES)
|
||||
_QUIZ_BOX_PAGE_TEMPLATES = list(screens.QUIZ_BOX_PAGE_TEMPLATES)
|
||||
_GLOBAL_VARIABLE_TYPES = sorted(db.GLOBAL_VARIABLE_TYPES)
|
||||
# Catalogue COMPLET (public + admin-only, voir core/sprite_gate.py) —
|
||||
# Claude doit connaître les slugs valides pour ne jamais en deviner un
|
||||
@@ -210,6 +213,32 @@ TOOLS = [
|
||||
"required": ["object_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "set_quiz_box_config",
|
||||
"description": (
|
||||
"Règle les options d'une \"❓ Boîte à quiz\" pensées pour un quiz AUTONOME "
|
||||
"(RH/formation) : plein écran, minuteur (jamais imposé — \"aucun\" par défaut, "
|
||||
"au choix du créateur : \"question\" redémarre à chaque question, \"quiz\" est un "
|
||||
"seul compte à rebours pour tout le quiz), et DEUX modèles visuels INDÉPENDANTS "
|
||||
"(les deux réglages sont conservés en même temps, jamais l'un n'écrase l'autre) : "
|
||||
"dialog_template (visible SEULEMENT hors plein écran — variantes de forme/couleur "
|
||||
"sur une structure proche de \"Classique\") et page_template (visible SEULEMENT en "
|
||||
"plein écran — thème complet et autonome, structure HTML propre à chaque modèle). "
|
||||
"En plein écran, le score se retrouve affiché directement dans la boîte."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object_id": {"type": "integer"},
|
||||
"fullscreen": {"type": "boolean"},
|
||||
"timer_mode": {"type": "string", "enum": _QUIZ_BOX_TIMER_MODES},
|
||||
"timer_seconds": {"type": "integer", "description": "Durée du minuteur (3 à 600s), ignorée si timer_mode=\"aucun\"."},
|
||||
"dialog_template": {"type": "string", "enum": _QUIZ_BOX_DIALOG_TEMPLATES, "description": "Modèle \"boîte de dialogue\" — ne s'affiche que si fullscreen=false."},
|
||||
"page_template": {"type": "string", "enum": _QUIZ_BOX_PAGE_TEMPLATES, "description": "Modèle \"page de quiz\" — ne s'affiche que si fullscreen=true."},
|
||||
},
|
||||
"required": ["object_id"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "create_global_variable",
|
||||
"description": "Crée une variable globale (idempotent par nom) — utilisable ensuite dans les déclencheurs/conditions.",
|
||||
@@ -502,6 +531,27 @@ def _dispatch_set_object_collision(slug, screen_id, user_id, object_id, enabled=
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dispatch_set_quiz_box_config(slug, screen_id, user_id, object_id, fullscreen=False,
|
||||
timer_mode="aucun", timer_seconds=20,
|
||||
dialog_template="defaut", page_template="classique"):
|
||||
# Même forme que screens.set_scene_object_quiz_config (remplacement
|
||||
# complet des réglages, jamais un merge partiel) — screens.py sanitize
|
||||
# de toute façon toute valeur hors énumération, ce garde-fou ici sert
|
||||
# juste à ne jamais transmettre None à sanitize_quiz_box_config.
|
||||
# dialog_template/page_template sont conservés INDÉPENDAMMENT (voir
|
||||
# screens/rendering/quiz_box_config.py) : Ruby peut en régler un seul
|
||||
# sans jamais écraser l'autre.
|
||||
config = {
|
||||
"fullscreen": bool(fullscreen),
|
||||
"timer_mode": timer_mode if timer_mode in _QUIZ_BOX_TIMER_MODES else "aucun",
|
||||
"timer_seconds": timer_seconds or 20,
|
||||
"dialog_template": dialog_template if dialog_template in _QUIZ_BOX_DIALOG_TEMPLATES else "defaut",
|
||||
"page_template": page_template if page_template in _QUIZ_BOX_PAGE_TEMPLATES else "classique",
|
||||
}
|
||||
screens.set_scene_object_quiz_config(slug, object_id, config)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _dispatch_create_global_variable(slug, screen_id, user_id, name, var_type="texte", default_value="", per_player=True):
|
||||
variable_id = db.create_global_variable(slug, name, var_type=var_type, default_value=default_value, per_player=per_player)
|
||||
return {"variable_id": variable_id}
|
||||
@@ -571,6 +621,7 @@ _DISPATCH = {
|
||||
"set_object_name": _dispatch_set_object_name,
|
||||
"set_object_role": _dispatch_set_object_role,
|
||||
"set_object_collision": _dispatch_set_object_collision,
|
||||
"set_quiz_box_config": _dispatch_set_quiz_box_config,
|
||||
"create_global_variable": _dispatch_create_global_variable,
|
||||
"set_collision_rules": _dispatch_set_collision_rules,
|
||||
"append_action_to_trigger": _dispatch_append_action_to_trigger,
|
||||
|
||||
@@ -8,5 +8,6 @@ from . import (
|
||||
scene_object_collision,
|
||||
scene_object_name,
|
||||
scene_object_dialogue_box_style,
|
||||
scene_object_quiz_config,
|
||||
scene_object_names_list,
|
||||
)
|
||||
|
||||
@@ -22,6 +22,18 @@ def render_scene_edit(slug, screen_id, game):
|
||||
objects = screens.list_scene_objects(slug, screen_id)
|
||||
for o in objects:
|
||||
o["rendered_html"] = screens.render_scene_object(o)
|
||||
# "Plein écran" (demande explicite : la boîte à quiz doit occuper
|
||||
# tout le canevas de CET éditeur, pas seulement au jeu, dès que ce
|
||||
# réglage est coché) — calculé pour TOUS les objets (pas
|
||||
# seulement le sélectionné) : plusieurs quiz plein écran sur le
|
||||
# même écran resteraient rares/déconseillés, mais rien n'empêche
|
||||
# techniquement d'en poser plusieurs.
|
||||
o["quiz_fullscreen"] = o["kind"] == "quiz_box" and screens.resolve_quiz_box_config(o)["fullscreen"]
|
||||
# La "notion de caméra disparaît" (demande explicite) : le cadre "🎥
|
||||
# Champ de la caméra" n'a plus aucun sens dès qu'un quiz plein écran
|
||||
# est posé sur cet écran, caméra ou pas — masqué dans ce cas (voir
|
||||
# templates/scene_edit.html).
|
||||
any_quiz_box_fullscreen = any(o["quiz_fullscreen"] for o in objects)
|
||||
|
||||
# "Monde" (voir screens.resolve_scene_world_size, réutilisé aussi par
|
||||
# la miniature de "Mes écrans", routes/games/game_dashboard.py) : un
|
||||
@@ -41,6 +53,7 @@ def render_scene_edit(slug, screen_id, game):
|
||||
personnage_role = None
|
||||
collision_settings = None
|
||||
dialogue_box_style = None
|
||||
quiz_box_config = None
|
||||
selected_obj = next((o for o in objects if o["id"] == selected_id), None) if selected_id else None
|
||||
_UI_WIDGET_KINDS = ("dialogue_box", "quiz_box", "score_widget")
|
||||
if selected_obj:
|
||||
@@ -52,6 +65,12 @@ def render_scene_edit(slug, screen_id, game):
|
||||
personnage_role = screens.resolve_personnage_role(selected_obj)
|
||||
elif selected_obj["kind"] in _UI_WIDGET_KINDS:
|
||||
dialogue_box_style = screens.resolve_dialogue_box_style(selected_obj)
|
||||
if selected_obj["kind"] == "quiz_box":
|
||||
# Plein écran/minuteur/modèle (demande explicite : quiz RH
|
||||
# autonome) — voir screens/rendering/quiz_box_config.py,
|
||||
# PANNEAU distinct de "🎨 Style" ci-dessus (couleurs/police,
|
||||
# partagé avec dialogue_box/score_widget).
|
||||
quiz_box_config = screens.resolve_quiz_box_config(selected_obj)
|
||||
|
||||
_DEFAULT_OBJECT_LABELS = {"personnage": "Personnage", "fond": "Fond"}
|
||||
object_labels = {
|
||||
@@ -117,6 +136,7 @@ def render_scene_edit(slug, screen_id, game):
|
||||
"scene_edit.html",
|
||||
game=game, screen=screen, all_screens=all_screens,
|
||||
world_width=world_width, world_height=world_height,
|
||||
any_quiz_box_fullscreen=any_quiz_box_fullscreen,
|
||||
objects=objects, selected_id=selected_id, personnage_data=personnage_data,
|
||||
personnage_commands=personnage_commands or screens.DEFAULT_PERSONNAGE_COMMANDS,
|
||||
personnage_role=personnage_role or screens.DEFAULT_PERSONNAGE_ROLE,
|
||||
@@ -124,6 +144,12 @@ def render_scene_edit(slug, screen_id, game):
|
||||
collision_settings=collision_settings or screens.DEFAULT_COLLISION_SETTINGS,
|
||||
dialogue_box_style=dialogue_box_style or screens.DEFAULT_DIALOGUE_BOX_STYLE,
|
||||
dialogue_box_font_weight_choices=screens.FONT_WEIGHT_CHOICES,
|
||||
quiz_box_config=quiz_box_config or screens.DEFAULT_QUIZ_BOX_CONFIG,
|
||||
quiz_box_timer_modes=screens.TIMER_MODES,
|
||||
quiz_box_dialog_templates=screens.QUIZ_BOX_DIALOG_TEMPLATES,
|
||||
quiz_box_dialog_template_labels=screens.QUIZ_BOX_DIALOG_TEMPLATE_LABELS,
|
||||
quiz_box_page_templates=screens.QUIZ_BOX_PAGE_TEMPLATES,
|
||||
quiz_box_page_template_labels=screens.QUIZ_BOX_PAGE_TEMPLATE_LABELS,
|
||||
sprite_library=sprite_library, sprite_library_families=sprite_library_families,
|
||||
sprite_library_json=json.dumps(sprite_library),
|
||||
background_library=background_library,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
from flask import request, jsonify
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/scene-objects/<int:object_id>/quiz-config", methods=["POST"])
|
||||
def scene_object_quiz_config(slug, object_id):
|
||||
"""Réglages "❓ Boîte à quiz" (plein écran/minuteur/modèle, voir
|
||||
screens/rendering/quiz_box_config.py) — mirror exact de
|
||||
routes/scenes/scene_object_dialogue_box_style.py."""
|
||||
body = request.get_json(silent=True) or {}
|
||||
config = screens.set_scene_object_quiz_config(slug, object_id, body.get("config"))
|
||||
if config is None:
|
||||
return jsonify({"error": "objet introuvable"}), 404
|
||||
# Un modèle change la STRUCTURE du balisage, pas juste une classe CSS
|
||||
# (voir screens/rendering/quiz_box_templates.py) — renvoyer le HTML
|
||||
# déjà rendu permet à scene-editor.js de remplacer l'aperçu en un
|
||||
# coup, plutôt que de deviner quelle balise ajouter/retirer côté
|
||||
# client.
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
return jsonify({"config": config, "rendered_html": screens.render_scene_object(obj)})
|
||||
@@ -63,6 +63,11 @@ from .labels.video_modes import VIDEO_MODES, VIDEO_MODE_LABELS
|
||||
from .rendering.personnage_commands import resolve_personnage_commands, DEFAULT_PERSONNAGE_COMMANDS, sanitize_extra_commands
|
||||
from .rendering.scene_object_names import list_named_scene_object_names
|
||||
from .rendering.dialogue_box_style import resolve_dialogue_box_style, sanitize_dialogue_box_style, DEFAULT_DIALOGUE_BOX_STYLE, FONT_WEIGHT_CHOICES
|
||||
from .rendering.quiz_box_config import (
|
||||
resolve_quiz_box_config, sanitize_quiz_box_config, DEFAULT_QUIZ_BOX_CONFIG,
|
||||
TIMER_MODES, QUIZ_BOX_DIALOG_TEMPLATES, QUIZ_BOX_DIALOG_TEMPLATE_LABELS,
|
||||
QUIZ_BOX_PAGE_TEMPLATES, QUIZ_BOX_PAGE_TEMPLATE_LABELS,
|
||||
)
|
||||
|
||||
from .screens_repo.ensure_schema import ensure_schema
|
||||
from .screens_repo.list_screens import list_screens
|
||||
@@ -93,6 +98,7 @@ from .scenes.set_scene_object_collision import set_scene_object_collision
|
||||
from .scenes.set_scene_object_collision_rules import set_scene_object_collision_rules
|
||||
from .scenes.set_scene_object_name import set_scene_object_name
|
||||
from .scenes.set_scene_object_dialogue_box_style import set_scene_object_dialogue_box_style
|
||||
from .scenes.set_scene_object_quiz_config import set_scene_object_quiz_config
|
||||
from .scenes.render_scene_object import render_scene_object
|
||||
from .scenes.build_collision_rule_objects import build_collision_rule_objects
|
||||
from .scenes.append_action_to_trigger import append_action_to_trigger
|
||||
|
||||
@@ -11,6 +11,7 @@ from ..rendering.collision_settings import resolve_collision_settings
|
||||
from ..rendering.collision_rules import resolve_collision_rules
|
||||
from ..rendering.screen_triggers import resolve_screen_triggers
|
||||
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||
from ..rendering.quiz_box_config import resolve_quiz_box_config
|
||||
from ..rendering.trigger_graph import list_completable_dialogue_ids
|
||||
from ..scenes.list_scene_objects import list_scene_objects
|
||||
from ..scenes.render_scene_object import render_scene_object
|
||||
@@ -62,6 +63,13 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# collision, ni cible/source de règle — juste leur
|
||||
# style, lu par static/js/play/dialogue-box-controller.js.
|
||||
o["dialogue_box_style"] = resolve_dialogue_box_style(o)
|
||||
if o["kind"] == "quiz_box":
|
||||
# Plein écran/minuteur/modèle (voir screens/rendering/
|
||||
# quiz_box_config.py) — même convention que
|
||||
# dialogue_box_style ci-dessus (cohérence de contrat),
|
||||
# même si le JS runtime lit en pratique les data-*
|
||||
# déjà posés sur rendered_html.
|
||||
o["quiz_config"] = resolve_quiz_box_config(o)
|
||||
continue
|
||||
# Boîte de collision (voir static/js/play/conditions.js::
|
||||
# elementsOverlap) : posée sur TOUT objet de scène, pas
|
||||
@@ -83,6 +91,19 @@ def full_game_payload(slug, player_id=db.PLAYER_SHARED):
|
||||
# colonne brute _screen_triggers (laissée telle quelle par ailleurs
|
||||
# dans `s`, comme le reste des colonnes _screens).
|
||||
s["screen_triggers"] = resolve_screen_triggers(s)
|
||||
# "La notion de caméra disparaît" quand un quiz plein écran existe
|
||||
# sur cet écran (demande explicite) — pas seulement le widget lui-
|
||||
# même : ".playScreen.playScene" (templates/play.html), dont la
|
||||
# taille EST la "caméra" (scene_width/scene_height) et qui reste
|
||||
# centré via un `transform`, casse `position:fixed` pour tout
|
||||
# descendant (voir static/js/play/dialogue-box-controller.js::
|
||||
# forgeEscapeCameraForFullscreen). Exposé ici pour que play.html
|
||||
# rende directement CET écran en plein viewport, sans transform ni
|
||||
# taille caméra — le réglage "Caméra (px)" ne s'applique alors plus
|
||||
# du tout tant que ce quiz reste plein écran.
|
||||
s["any_quiz_box_fullscreen"] = any(
|
||||
o["kind"] == "quiz_box" and o.get("quiz_config", {}).get("fullscreen") for o in objects
|
||||
)
|
||||
payload_screens.append({**s, "elements": objects})
|
||||
|
||||
# Le graphe de logique et les animations sont, eux, lus pour TOUS les
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import json
|
||||
|
||||
# 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") — DISTINCT de "🎨 Style"
|
||||
# (dialogue_box_style.py, partagé avec dialogue_box/score_widget) : ce
|
||||
# fichier ne gère QUE plein écran/minuteur/modèle, jamais police/couleur.
|
||||
#
|
||||
# DEUX catégories de modèles bien distinctes (demande explicite, précisée
|
||||
# après coup — les 5 premiers modèles construits avant cette précision
|
||||
# étaient tous de la première catégorie) :
|
||||
# - "dialog_template" (modèles "boîte de dialogue", visibles SEULEMENT
|
||||
# quand fullscreen=False) : variantes de forme/couleur/interaction sur
|
||||
# une structure HTML qui reste globalement la même que "Classique".
|
||||
# - "page_template" (modèles "page de quiz", visibles SEULEMENT quand
|
||||
# fullscreen=True) : thème complet et autonome — police, formes,
|
||||
# couleurs ET structure HTML totalement propres à CHAQUE modèle,
|
||||
# aucune structure partagée entre eux ni avec les modèles "boîte".
|
||||
# Les deux réglages sont conservés INDÉPENDAMMENT (jamais écrasés l'un par
|
||||
# l'autre) : activer/désactiver le plein écran change juste lequel des
|
||||
# deux s'applique, sans jamais perdre le choix de l'autre.
|
||||
DEFAULT_QUIZ_BOX_CONFIG = {
|
||||
"fullscreen": False,
|
||||
"timer_mode": "aucun",
|
||||
"timer_seconds": 20,
|
||||
"dialog_template": "defaut",
|
||||
"page_template": "classique",
|
||||
}
|
||||
|
||||
# "aucun" : pas de minuteur (réglage par défaut, jamais imposé — demande
|
||||
# explicite du créateur). "question" : redémarre à chaque question.
|
||||
# "quiz" : un seul compte à rebours pour l'ensemble du quiz.
|
||||
TIMER_MODES = ["aucun", "question", "quiz"]
|
||||
|
||||
# "defaut" (Classique) = le CSS/HTML déjà existant, AUCUN changement
|
||||
# visuel pour un quiz non reconfiguré (voir quiz_box_templates.py) —
|
||||
# "Verre dépoli"/"Néon"/"Minimal"/"Ludique" ont été retirés (demande
|
||||
# explicite : "supprime tous les modele de boite sauf le base").
|
||||
# "manga_dialogue" (Manga) : pendant, en petite carte flottante (jamais
|
||||
# plein écran), du modèle "page de quiz" "Manga" — validé par le créateur
|
||||
# via une maquette interactive avant intégration (voir
|
||||
# screens/rendering/quiz_box_templates.py::render_quiz_box_manga_dialogue).
|
||||
# Clé DIFFÉRENTE du modèle "page" "manga" (registre d'effets JS partagé
|
||||
# entre les deux catégories, voir static/js/play/quiz-box-templates.js)
|
||||
# — reste proche de la structure de "Classique" (demande explicite :
|
||||
# les modèles "boîte" ne changent que forme/couleur/interaction).
|
||||
QUIZ_BOX_DIALOG_TEMPLATES = ["defaut", "manga_dialogue"]
|
||||
QUIZ_BOX_DIALOG_TEMPLATE_LABELS = {
|
||||
"defaut": "Classique",
|
||||
"manga_dialogue": "Manga",
|
||||
}
|
||||
|
||||
# "classique" (Classique) : modèle de PAGE DE QUIZ de base — sobre/
|
||||
# professionnel, reprend les couleurs par défaut du modèle "boîte de
|
||||
# dialogue" de base (demande explicite : "respecte les couleurs du modele
|
||||
# de boite de base pour le modele de page de base") plutôt que sa propre
|
||||
# palette, contrairement à "manga" qui est un thème pleinement autonome.
|
||||
# "manga" : thème/typographie/structure entièrement dédiés, validé par le
|
||||
# créateur via une maquette interactive avant intégration (voir
|
||||
# screens/rendering/quiz_box_templates.py::render_quiz_box_page_manga).
|
||||
QUIZ_BOX_PAGE_TEMPLATES = ["classique", "manga"]
|
||||
QUIZ_BOX_PAGE_TEMPLATE_LABELS = {
|
||||
"classique": "Classique",
|
||||
"manga": "Manga",
|
||||
}
|
||||
|
||||
_MIN_TIMER_SECONDS = 3
|
||||
_MAX_TIMER_SECONDS = 600
|
||||
|
||||
|
||||
def sanitize_quiz_box_config(raw_config):
|
||||
config = dict(DEFAULT_QUIZ_BOX_CONFIG)
|
||||
if not isinstance(raw_config, dict):
|
||||
return config
|
||||
config["fullscreen"] = bool(raw_config.get("fullscreen"))
|
||||
if raw_config.get("timer_mode") in TIMER_MODES:
|
||||
config["timer_mode"] = raw_config["timer_mode"]
|
||||
try:
|
||||
config["timer_seconds"] = max(_MIN_TIMER_SECONDS, min(_MAX_TIMER_SECONDS, int(raw_config.get("timer_seconds"))))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if raw_config.get("dialog_template") in QUIZ_BOX_DIALOG_TEMPLATES:
|
||||
config["dialog_template"] = raw_config["dialog_template"]
|
||||
if raw_config.get("page_template") in QUIZ_BOX_PAGE_TEMPLATES:
|
||||
config["page_template"] = raw_config["page_template"]
|
||||
return config
|
||||
|
||||
|
||||
def resolve_quiz_box_config(obj):
|
||||
raw = (obj.get("attributes") or {}).get("_quiz_box_config")
|
||||
if not raw:
|
||||
return dict(DEFAULT_QUIZ_BOX_CONFIG)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
return dict(DEFAULT_QUIZ_BOX_CONFIG)
|
||||
return sanitize_quiz_box_config(parsed)
|
||||
@@ -0,0 +1,337 @@
|
||||
import html as html_lib
|
||||
|
||||
# ---------- "❓ Boîte à quiz" — DEUX catégories de modèles bien distinctes
|
||||
# (voir screens/rendering/quiz_box_config.py) :
|
||||
# - "boîte de dialogue" (dialog_template, visibles SEULEMENT hors plein
|
||||
# écran) : "defaut" (Classique) + "manga_dialogue" (Manga), voir
|
||||
# render_quiz_box_manga_dialogue plus bas — "Verre dépoli"/"Néon"/
|
||||
# "Minimal"/"Ludique" ont été retirés (demande explicite : "supprime
|
||||
# tous les modele de boite sauf le base") ;
|
||||
# - "page de quiz" (page_template, visibles SEULEMENT en plein écran,
|
||||
# voir render_quiz_box_page_classique/render_quiz_box_page_manga plus
|
||||
# bas) : thème complet et autonome, structure HTML totalement propre
|
||||
# à CHAQUE modèle, aucune structure partagée avec les modèles
|
||||
# "boîte" ni entre eux.
|
||||
# Chaque modèle a son propre bloc CSS (static/style.css) + éventuellement
|
||||
# son propre effet JS (static/js/play/quiz-box-templates.js), mais TOUS
|
||||
# doivent respecter le même "contrat" de points d'ancrage pour que le
|
||||
# moteur PARTAGÉ (static/js/play/dialogue-box-controller.js) fonctionne
|
||||
# quel que soit le modèle choisi :
|
||||
# - le conteneur racine porte class="quizBoxWidget[ quizBoxWidget--X]",
|
||||
# data-object-id/data-element-id, data-quiz-template, data-fullscreen,
|
||||
# data-timer-mode, data-timer-seconds (voir _quiz_data_attrs) ;
|
||||
# - [data-quiz-role="header"] / "question" / "choices" — JS y écrit
|
||||
# TOUJOURS via .textContent ou remplace .innerHTML en entier (jamais
|
||||
# un ajout) : ne JAMAIS poser de décoration (icône, badge, score...)
|
||||
# comme ENFANT de ces éléments précis, elle serait effacée au premier
|
||||
# rafraîchissement — un ::before CSS est la seule façon sûre d'ajouter
|
||||
# une icône à ces zones-là. C'est pourquoi le score/minuteur (voir
|
||||
# juste en dessous) vit dans un élément FRÈRE du header (jamais son
|
||||
# enfant), même si visuellement il doit apparaître à l'intérieur du
|
||||
# même bandeau (demande explicite : "le score doit être à l'intérieur
|
||||
# de la boîte à quiz, pas à l'extérieur") — voir _quiz_header_row_html,
|
||||
# qui les pose côte à côte dans un même bandeau partageant le même
|
||||
# fond, plutôt qu'en deux bandes empilées de couleurs différentes ;
|
||||
# - [data-quiz-role="score"]/[data-quiz-role="timer"] — un modèle
|
||||
# "boîte" les regroupe dans un [data-quiz-role="topbar"] (masqué hors
|
||||
# plein écran par static/style.css) ; un modèle "page" n'a pas besoin
|
||||
# de ce regroupement/masquage (il n'existe QUE plein écran) et peut
|
||||
# les placer où il veut (voir render_quiz_box_page_manga) — le moteur
|
||||
# JS ne cherche jamais "topbar" lui-même, seulement score/timer.
|
||||
# "defaut" (Classique) reste visuellement identique au style déjà en
|
||||
# place avant cette extension (pas de suffixe de classe) — voir
|
||||
# render_quiz_box_defaut.
|
||||
|
||||
|
||||
def _quiz_data_attrs(obj, config, active_template):
|
||||
return (
|
||||
f'data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" '
|
||||
f'data-quiz-template="{active_template}" '
|
||||
f'data-fullscreen="{"1" if config["fullscreen"] else "0"}" '
|
||||
f'data-timer-mode="{config["timer_mode"]}" data-timer-seconds="{config["timer_seconds"]}"'
|
||||
)
|
||||
|
||||
|
||||
def _quiz_box_style(obj, style):
|
||||
return (
|
||||
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
||||
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
|
||||
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
|
||||
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
|
||||
)
|
||||
|
||||
|
||||
def _quiz_topbar_html():
|
||||
return (
|
||||
'<div class="quizBoxTopBar" data-quiz-role="topbar">'
|
||||
'<span class="quizBoxScoreDisplay" data-quiz-role="score">Score : 0</span>'
|
||||
'<span class="quizBoxTimerDisplay" data-quiz-role="timer"></span>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def _quiz_header_row_html(header_text, header_bg=None):
|
||||
# Le score/minuteur (topbar) est un FRÈRE du header, jamais son enfant
|
||||
# (voir le commentaire d'en-tête) — les deux partagent ici le MÊME
|
||||
# bandeau (fond commun posé sur ".quizBoxHeaderRow", jamais sur le
|
||||
# header seul) pour que le score, une fois visible en plein écran,
|
||||
# apparaisse comme faisant partie du même bloc plutôt qu'une bande à
|
||||
# part au-dessus (demande explicite). `header_bg=None` : aucun fond
|
||||
# posé, le bandeau reste transparent (repli disponible pour un futur
|
||||
# modèle qui n'en aurait pas besoin).
|
||||
bg_style = f' style="background:{html_lib.escape(header_bg)}"' if header_bg else ''
|
||||
return (
|
||||
f'<div class="quizBoxHeaderRow"{bg_style}>'
|
||||
f'<div class="quizBoxHeader" data-quiz-role="header">{header_text}</div>'
|
||||
+ _quiz_topbar_html() +
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def render_quiz_box_defaut(obj, config, style):
|
||||
return (
|
||||
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
|
||||
+ _quiz_header_row_html("Quête : Titre de la quête", style["header_bg"]) +
|
||||
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
|
||||
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
|
||||
f'<div class="quizBoxChoices" data-quiz-role="choices">'
|
||||
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
|
||||
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
|
||||
f'</div></div></div>'
|
||||
)
|
||||
|
||||
|
||||
def render_quiz_box_manga_dialogue(obj, config, style):
|
||||
# "Manga" (boîte de dialogue) — pendant, en petite carte flottante
|
||||
# JAMAIS plein écran, du modèle "page de quiz" "Manga" (voir
|
||||
# render_quiz_box_page_manga), validé par le créateur via une
|
||||
# maquette interactive avant intégration. Réutilise TELLE QUELLE la
|
||||
# structure partagée des modèles "boîte" (quizBoxHeaderRow/
|
||||
# quizBoxHeader/quizBoxTopBar/quizBoxBody/quizBoxQuestionText/
|
||||
# quizBoxChoices/quizBoxChoiceBtn) — seule la DÉCORATION change (voir
|
||||
# CSS), jamais la structure (demande explicite : les modèles "boîte"
|
||||
# "s'éloignent peu du modèle basic", contrairement aux modèles
|
||||
# "page" qui ont chacun leur structure propre).
|
||||
# .quizMangaDialogueFrame : enveloppe portant la forme dentelée/le
|
||||
# cadre (voir CSS) SANS l'appliquer au conteneur racine (qui doit
|
||||
# rester positionnable/redimensionnable normalement) — même principe
|
||||
# que les anciens modèles "Verre dépoli"/"Néon".
|
||||
# "Titre de la quête" seul ici (pas "Quête : Titre de la quête") : le
|
||||
# préfixe "Quête : " est posé en CSS pur (::before sur .quizBoxHeader,
|
||||
# voir static/style.css) pour survivre à l'écrasement JS du header
|
||||
# (voir le commentaire de classe ci-dessus) — l'écrire aussi ici le
|
||||
# doublerait dans l'aperçu éditeur (jamais réécrit par le JS avant le
|
||||
# premier vrai jeu).
|
||||
return (
|
||||
f'<div class="quizBoxWidget quizBoxWidget--mangaDialogue" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["dialog_template"])} style="{_quiz_box_style(obj, style)}">'
|
||||
'<div class="quizMangaDialogueFrame">'
|
||||
+ _quiz_header_row_html("Titre de la quête", style["header_bg"]) +
|
||||
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
|
||||
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
|
||||
f'<div class="quizBoxChoices" data-quiz-role="choices">'
|
||||
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
|
||||
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
|
||||
# Repère visuel SEULEMENT (aperçu éditeur, jamais reconfiguré) —
|
||||
# le vrai bouton est désormais TOUJOURS présent dès la question
|
||||
# (voir forgeShowQuizBox, dialogue-box-controller.js — posé
|
||||
# désactivé, activé par forgeQuizBoxAnswer une fois répondu,
|
||||
# demande explicite), donc ce repère est maintenant fidèle au
|
||||
# comportement réel, pas juste à l'aperçu éditeur.
|
||||
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
|
||||
'</div></div>'
|
||||
'</div>'
|
||||
# Bandeau de pied de page TOUJOURS visible (comme la maquette
|
||||
# validée) — purement décoratif (aria-hidden, jamais un
|
||||
# data-quiz-role), le moteur JS partagé ne le touche JAMAIS : le
|
||||
# bouton "Continuer →" qu'il injecte dans .quizBoxChoices se pose
|
||||
# PAR-DESSUS en position:absolute (voir static/style.css), pas
|
||||
# DANS ce bandeau — même principe que le modèle "page" "Classique".
|
||||
# FRÈRE de .quizMangaDialogueFrame (jamais dedans) : .quizMangaDialogueFrame
|
||||
# a overflow:hidden (pour la forme dentelée du corps), qui
|
||||
# rognerait ce bandeau/le bouton s'ils étaient nichés à
|
||||
# l'intérieur au lieu d'être positionnés par-dessus TOUT le
|
||||
# widget (.quizBoxWidget--mangaDialogue est position:relative).
|
||||
'<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def render_quiz_box_page_manga(obj, config, style):
|
||||
# "Manga" — premier modèle "PAGE DE QUIZ" (voir screens/rendering/
|
||||
# quiz_box_config.py::QUIZ_BOX_PAGE_TEMPLATES) : thème complet et
|
||||
# autonome, validé par le créateur via une maquette interactive avant
|
||||
# intégration — structure HTML totalement propre à ce modèle, AUCUNE
|
||||
# réutilisation de _quiz_header_row_html/quizBoxHeader/quizBoxBody des
|
||||
# modèles "boîte" ci-dessus (demande explicite : "la structure du html
|
||||
# qui change totalement"). Respecte quand même le contrat minimal
|
||||
# (data-quiz-role="header"/"question"/"choices"/"score"/"timer") pour
|
||||
# que le moteur JS partagé fonctionne sans modification.
|
||||
#
|
||||
# Score/minuteur : PAS de "topbar" à masquer/afficher ici (ce modèle
|
||||
# n'existe QUE plein écran, il n'y a jamais lieu de les cacher) — deux
|
||||
# plaques séparées de part et d'autre d'un cartouche de titre, comme
|
||||
# dans la maquette validée. Les lettres A/B/C/D + le tampon "正解"/
|
||||
# "不正解" à la révélation sont posés en CSS pur (::before/::after,
|
||||
# voir static/style.css) : les boutons de choix injectés par le
|
||||
# moteur JS (static/js/play/dialogue-box-controller.js) restent de
|
||||
# simples <button class="quizBoxChoiceBtn"> sans balisage
|
||||
# supplémentaire à maintenir ici.
|
||||
# "Temps" reste toujours visible, même sans minuteur configuré (demande
|
||||
# explicite : "si il n'y a pas de chrono marque en dessous de temps
|
||||
# aucun") — le texte initial ici ("Aucun") n'est JAMAIS écrasé par le
|
||||
# moteur JS quand timer_mode="aucun" (voir forgeShowQuizBox,
|
||||
# dialogue-box-controller.js : aucune de ses branches ne touche
|
||||
# [data-quiz-role="timer"] dans ce cas), donc pas besoin d'un effet JS
|
||||
# dédié pour le maintenir à jour question après question.
|
||||
timer_placeholder = "Aucun" if config["timer_mode"] == "aucun" else ""
|
||||
return (
|
||||
f'<div class="quizBoxWidget quizBoxWidget--manga" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)}">'
|
||||
'<div class="mangaTopbar">'
|
||||
'<div class="mangaPlate mangaPlate--timer">'
|
||||
'<span class="mangaPlateLabel">Temps</span>'
|
||||
f'<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
|
||||
'</div>'
|
||||
'<div class="mangaTitlemark"><span>Manga Quiz</span></div>'
|
||||
'<div class="mangaPlate mangaPlate--score">'
|
||||
'<span class="mangaPlateLabel">Score</span>'
|
||||
'<span class="mangaPlateValue" data-quiz-role="score">Score : 0</span>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
# Barre d'étapes (voir static/js/play/quiz-box-templates.js::
|
||||
# forgeQuizMangaOnShow) — remplie EN JEU (nombre de questions total
|
||||
# connu seulement à l'exécution, jamais à la pose de l'objet) ;
|
||||
# data-manga-role (pas data-quiz-role) : décoration propre à CE
|
||||
# modèle, jamais du contrat partagé.
|
||||
'<div class="mangaProgress" data-manga-role="progress"></div>'
|
||||
'<div class="mangaPanelQuestion">'
|
||||
'<span class="mangaKicker" data-quiz-role="header">Titre de la quête</span>'
|
||||
'<p class="mangaQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
|
||||
'</div>'
|
||||
'<div class="mangaChoices" data-quiz-role="choices">'
|
||||
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
|
||||
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
|
||||
'</div>'
|
||||
# Écran de résultat de fin de quiz — voir forgeQuizMangaOnQuizComplete
|
||||
# (static/js/play/quiz-box-templates.js), même mécanique que
|
||||
# "classique" (render_quiz_box_page_classique) mais dans le thème
|
||||
# manga propre à ce modèle. Pas de conteneur "content" à part ici
|
||||
# (contrairement à .classiqueContent) : c'est la RACINE du widget
|
||||
# elle-même qui bascule en classe "is-showing-result" (voir
|
||||
# static/style.css) pour masquer question/choix, en gardant le
|
||||
# bandeau topbar/score + la barre d'étapes visibles. data-manga-
|
||||
# role (pas data-quiz-role) : décoration propre à ce modèle.
|
||||
'<div class="mangaResultZone" data-manga-role="result"></div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def render_quiz_box_page_classique(obj, config, style):
|
||||
# "Classique" — modèle de PAGE DE QUIZ DE BASE (sobre/professionnel),
|
||||
# validé par le créateur via une maquette interactive (formation
|
||||
# sécurité incendie comme exemple). Reprend VOLONTAIREMENT les
|
||||
# couleurs par défaut du modèle "boîte de dialogue" de base
|
||||
# (header_bg/body_bg/text_color, voir dialogue_box_style.py) — demande
|
||||
# explicite : "respecte les couleurs du modele de boite de base pour
|
||||
# le modele de page de base" — plutôt que d'inventer sa propre
|
||||
# palette comme "manga".
|
||||
#
|
||||
# Titre = le champ "Nom" de CET objet (déjà existant aujourd'hui,
|
||||
# aucun nouveau champ "titre" nécessaire, demande explicite) — rendu
|
||||
# ICI, une bonne fois pour toutes côté serveur, jamais réécrit par le
|
||||
# moteur JS (à l'inverse de [data-quiz-role="header"] plus bas, qui
|
||||
# sert ici à autre chose : voir le commentaire dessus).
|
||||
title = html_lib.escape(obj.get("name") or "Quiz")
|
||||
timer_placeholder = "Aucun" if config["timer_mode"] == "aucun" else ""
|
||||
return (
|
||||
# `--classique-footer-bg` (custom property posée sur la racine) :
|
||||
# lue par .classiqueChoices .quizBoxContinueBtn (voir plus bas) —
|
||||
# le bouton "Continuer →" du moteur partagé (injecté APRÈS coup
|
||||
# dans [data-quiz-role="choices"]) se pose en bandeau bas façon
|
||||
# pied de page, dans la couleur configurée via "🎨 Style"
|
||||
# (footer_bg), jamais une couleur figée en dur.
|
||||
f'<div class="quizBoxWidget quizBoxWidget--classique" role="dialog" aria-live="polite" aria-atomic="true" {_quiz_data_attrs(obj, config, config["page_template"])} style="{_quiz_box_style(obj, style)} --classique-footer-bg:{html_lib.escape(style["footer_bg"])}; --classique-body-bg:{html_lib.escape(style["body_bg"])};">'
|
||||
f'<div class="classiqueHeaderBar" style="background:{html_lib.escape(style["header_bg"])}">'
|
||||
'<div class="classiqueHeaderTitleWrap">'
|
||||
'<span class="classiqueHeaderEyebrow">❓ Quiz</span>'
|
||||
f'<span class="classiqueHeaderTitle">{title}</span>'
|
||||
'</div>'
|
||||
'<div class="classiqueHeaderChips">'
|
||||
'<div class="classiqueChip classiqueChip--timer">'
|
||||
'<span class="classiqueChipLabel">Temps</span>'
|
||||
f'<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">{timer_placeholder}</span>'
|
||||
'</div>'
|
||||
'<div class="classiqueChip classiqueChip--score">'
|
||||
'<span class="classiqueChipLabel">Score</span>'
|
||||
'<span class="classiqueChipValue" data-quiz-role="score">Score : 0</span>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
f'<div class="classiqueBody" style="background:{html_lib.escape(style["body_bg"])}">'
|
||||
'<div class="classiqueContent">'
|
||||
# Barre d'étapes — même principe que "manga" (voir
|
||||
# forgeQuizProgressOnShow, static/js/play/quiz-box-templates.js),
|
||||
# remplie EN JEU (nombre de questions connu seulement à
|
||||
# l'exécution) ; data-classique-role (pas data-quiz-role) :
|
||||
# décoration propre à CE modèle, jamais du contrat partagé.
|
||||
'<div class="classiqueProgress" data-classique-role="progress"></div>'
|
||||
# [data-quiz-role="header"] sert ICI d'étiquette "Question N/M"
|
||||
# (voir forgeQuizClassiqueOnShow) — JAMAIS le titre du quiz
|
||||
# (classiqueHeaderTitle ci-dessus, statique).
|
||||
'<span class="classiqueKicker" data-quiz-role="header">Question</span>'
|
||||
'<p class="classiqueQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
|
||||
'<div class="classiqueChoices" data-quiz-role="choices">'
|
||||
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
|
||||
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
|
||||
'</div>'
|
||||
# Encart d'explication après réponse — RÉSERVÉ pour une
|
||||
# fonctionnalité FUTURE, PAS ENCORE implémentée (demande
|
||||
# explicite : "tu peut le mettre dans le modele mais ne
|
||||
# fonctionnera pas pour le moment il faudra attendre que la
|
||||
# fonctionnalité sois implémenté") : aucune donnée d'explication
|
||||
# n'existe aujourd'hui sur une question, donc AUCUN code JS
|
||||
# n'écrit ici pour l'instant — reste vide et caché (voir
|
||||
# static/style.css) jusqu'à ce que ce champ existe réellement.
|
||||
'<div class="classiqueExplain" data-quiz-role="explain"></div>'
|
||||
# Écran de résultat de fin de quiz — RESTE VIDE/CACHÉ tant que le
|
||||
# quiz n'est pas terminé (voir forgeQuizClassiqueOnQuizComplete,
|
||||
# static/js/play/quiz-box-templates.js, qui le remplit et bascule
|
||||
# .classiqueContent en classe "is-showing-result" — masquant en
|
||||
# CSS tout le reste : progression/question/choix). data-classique-
|
||||
# role (pas data-quiz-role) : décoration propre à ce modèle.
|
||||
'<div class="classiqueResultZone" data-classique-role="result"></div>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
# Bandeau de pied de page TOUJOURS visible (footer_bg, même
|
||||
# esprit que header/body ci-dessus) — purement décoratif
|
||||
# (aria-hidden, jamais un data-quiz-role), le moteur JS partagé
|
||||
# ne le touche JAMAIS : le bouton "Continuer →" qu'il injecte
|
||||
# dans .classiqueChoices se pose PAR-DESSUS en position:absolute
|
||||
# (voir static/style.css), pas DANS ce bandeau — demande
|
||||
# explicite : "il manque le footer foncé avec le bouton".
|
||||
'<div class="classiqueFooterBand" aria-hidden="true"></div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
QUIZ_BOX_DIALOG_RENDERERS = {
|
||||
"defaut": render_quiz_box_defaut,
|
||||
"manga_dialogue": render_quiz_box_manga_dialogue,
|
||||
}
|
||||
|
||||
QUIZ_BOX_PAGE_RENDERERS = {
|
||||
"classique": render_quiz_box_page_classique,
|
||||
"manga": render_quiz_box_page_manga,
|
||||
}
|
||||
|
||||
|
||||
def render_quiz_box(obj, config, style):
|
||||
"""Point d'entrée UNIQUE (voir screens/scenes/render_scene_object.py)
|
||||
— bascule entre les deux catégories de modèles selon `fullscreen`
|
||||
(demande explicite : ces modèles ne se voient JAMAIS dans l'autre
|
||||
mode) plutôt que de laisser l'appelant deviner quelle liste
|
||||
consulter."""
|
||||
if config["fullscreen"]:
|
||||
renderer = QUIZ_BOX_PAGE_RENDERERS.get(config["page_template"], render_quiz_box_page_classique)
|
||||
else:
|
||||
renderer = QUIZ_BOX_DIALOG_RENDERERS.get(config["dialog_template"], render_quiz_box_defaut)
|
||||
return renderer(obj, config, style)
|
||||
@@ -2,6 +2,8 @@ import html as html_lib
|
||||
|
||||
from ..rendering.personnage_data import resolve_personnage_idle_frame
|
||||
from ..rendering.dialogue_box_style import resolve_dialogue_box_style
|
||||
from ..rendering.quiz_box_config import resolve_quiz_box_config
|
||||
from ..rendering.quiz_box_templates import render_quiz_box
|
||||
|
||||
|
||||
def render_scene_object(obj):
|
||||
@@ -72,31 +74,20 @@ def render_scene_object(obj):
|
||||
)
|
||||
if obj["kind"] == "quiz_box":
|
||||
# Widget "❓ Boîte à quiz" (voir "🖥️ Interface") — réutilise le
|
||||
# MÊME style que "💬 Boîte de dialogue" (resolve_dialogue_box_style/
|
||||
# set_scene_object_dialogue_box_style, une seule "🎨 Style" pour
|
||||
# les deux) : header = "Quête : <titre>" (comme demandé), corps =
|
||||
# la question ET ses choix de réponse ensemble (remplis EN JEU par
|
||||
# static/js/play/dialogue-box-controller.js, data-quiz-role sert de
|
||||
# sélecteur) — pas de pied (une question se résout au clic sur un
|
||||
# choix, pas de "Suivant"). Masqué par défaut au jeu, comme la
|
||||
# boîte de dialogue.
|
||||
# MÊME style couleur/police que "💬 Boîte de dialogue"
|
||||
# (resolve_dialogue_box_style/set_scene_object_dialogue_box_style,
|
||||
# une seule "🎨 Style" pour les deux, appliquée à TOUS les
|
||||
# modèles ci-dessous). Le rendu HTML lui-même dépend du modèle
|
||||
# choisi (plein écran/minuteur/modèle, voir
|
||||
# screens/rendering/quiz_box_config.py) — voir
|
||||
# screens/rendering/quiz_box_templates.py pour le détail de
|
||||
# chacun (thèmes complets HTML+CSS+JS, demande explicite) ; tous
|
||||
# respectent le même contrat de data-quiz-role, lu EN JEU par
|
||||
# static/js/play/dialogue-box-controller.js. "defaut" (Classique)
|
||||
# reste pixel-identique au rendu d'avant cette extension.
|
||||
style = resolve_dialogue_box_style(obj)
|
||||
box_style = (
|
||||
f"position:absolute; left:{obj['x']}px; top:{obj['y']}px; "
|
||||
f"width:{obj['width']}px; height:{obj['height']}px; z-index:{obj['z_index']}; "
|
||||
f"font-family:{html_lib.escape(style['font_family'])}; font-size:{style['font_size']}px; "
|
||||
f"font-weight:{style['font_weight']}; color:{html_lib.escape(style['text_color'])};"
|
||||
)
|
||||
return (
|
||||
f'<div class="quizBoxWidget" role="dialog" aria-live="polite" aria-atomic="true" data-object-id="{obj["id"]}" data-element-id="{obj["id"]}" style="{box_style}">'
|
||||
f'<div class="quizBoxHeader" data-quiz-role="header" style="background:{html_lib.escape(style["header_bg"])}">Quête : Titre de la quête</div>'
|
||||
f'<div class="quizBoxBody" data-quiz-role="body" style="background:{html_lib.escape(style["body_bg"])}">'
|
||||
f'<p class="quizBoxQuestionText" data-quiz-role="question">La question s\'affiche ici.</p>'
|
||||
f'<div class="quizBoxChoices" data-quiz-role="choices">'
|
||||
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
|
||||
f'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
|
||||
f'</div></div></div>'
|
||||
)
|
||||
config = resolve_quiz_box_config(obj)
|
||||
return render_quiz_box(obj, config, style)
|
||||
if obj["kind"] == "score_widget":
|
||||
# Widget "🏆 Score" (voir "🖥️ Interface") — toujours VISIBLE une
|
||||
# fois posé (pas de collision/déclencheur, contrairement aux deux
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import json
|
||||
|
||||
import db
|
||||
|
||||
from ..rendering.quiz_box_config import sanitize_quiz_box_config
|
||||
from .ensure_scene_schema import ensure_scene_schema
|
||||
|
||||
|
||||
def set_scene_object_quiz_config(slug, object_id, raw_config):
|
||||
ensure_scene_schema(slug)
|
||||
conn = db.connect(slug)
|
||||
row = conn.execute("SELECT attributes FROM _scene_objects WHERE id = ?", (object_id,)).fetchone()
|
||||
if not row:
|
||||
conn.close()
|
||||
return None
|
||||
attributes = json.loads(row["attributes"] or "{}")
|
||||
config = sanitize_quiz_box_config(raw_config)
|
||||
attributes["_quiz_box_config"] = json.dumps(config)
|
||||
conn.execute("UPDATE _scene_objects SET attributes = ? WHERE id = ?", (json.dumps(attributes), object_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return config
|
||||
@@ -14,9 +14,10 @@ global.gameData = {};
|
||||
|
||||
const {
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeUpdateAllScoreWidgets,
|
||||
forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout, forgeUpdateAllScoreWidgets,
|
||||
forgeAddQuizScore, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
forgeComputeFinalScormStatus, forgeEscapeCameraForFullscreen,
|
||||
forgeQuizResultQuit,
|
||||
} = require('../dialogue-box-controller.js');
|
||||
|
||||
function fakeWidget(objectId) {
|
||||
@@ -28,7 +29,10 @@ function fakeWidget(objectId) {
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
classList: {
|
||||
add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c),
|
||||
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
|
||||
},
|
||||
querySelector: sel => {
|
||||
if (sel === '[data-dialogue-role="header"]') return roles.header;
|
||||
if (sel === '[data-dialogue-role="body"]') return roles.body;
|
||||
@@ -39,6 +43,45 @@ function fakeWidget(objectId) {
|
||||
};
|
||||
}
|
||||
|
||||
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
|
||||
// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene"
|
||||
// (templates/play.html) est transformé (centré via translate), ce qui
|
||||
// casse position:fixed pour tout descendant : un plein écran ne peut
|
||||
// couvrir le VRAI viewport qu'en s'échappant de ce conteneur, comme
|
||||
// static/js/play/video-overlay.js le fait déjà pour son propre overlay.
|
||||
test('forgeEscapeCameraForFullscreen déplace le widget en enfant de <body> et retient l\'écran d\'origine', () => {
|
||||
const screenEl = { getAttribute: attr => (attr === 'data-screen-id' ? '7' : null) };
|
||||
const attrs = {};
|
||||
const classes = new Set();
|
||||
const bodyChildren = [];
|
||||
const body = { appendChild: el => { bodyChildren.push(el); el.parentNode = body; } };
|
||||
const widget = {
|
||||
parentNode: null,
|
||||
closest: sel => (sel === '.playScreen' ? screenEl : null),
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
classList: { add: c => classes.add(c) },
|
||||
};
|
||||
const previousDoc = global.document;
|
||||
global.document = { body };
|
||||
try {
|
||||
forgeEscapeCameraForFullscreen(widget);
|
||||
assert.equal(bodyChildren[0], widget);
|
||||
assert.equal(widget.parentNode, body);
|
||||
assert.equal(attrs['data-owner-screen-id'], '7');
|
||||
assert.equal(classes.has('quizBoxWidget--detachedForFullscreen'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeEscapeCameraForFullscreen ne fait rien si déjà enfant de <body> (jamais déplacé deux fois)', () => {
|
||||
const body = { appendChild: () => { throw new Error('ne doit pas être rappelé'); } };
|
||||
const widget = { parentNode: body };
|
||||
const previousDoc = global.document;
|
||||
global.document = { body };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeEscapeCameraForFullscreen(widget));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test.beforeEach(() => {
|
||||
global.window.currentScreenId = 1;
|
||||
// Compteurs de réussite (voir forgeComputeFinalScormStatus) : remis à
|
||||
@@ -228,27 +271,58 @@ function fakeQuizChoiceBtn() {
|
||||
return { disabled: false, classList: { add: c => classes.add(c), contains: c => classes.has(c) }, _classes: classes };
|
||||
}
|
||||
|
||||
function fakeQuizWidget(objectId, choiceCount) {
|
||||
const roles = { header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' } };
|
||||
function fakeQuizWidget(objectId, choiceCount, attrs) {
|
||||
attrs = attrs || {};
|
||||
const roles = {
|
||||
header: { textContent: '' }, question: { textContent: '' }, choices: { innerHTML: '' },
|
||||
score: { textContent: '' }, timer: { textContent: '', classList: { add() {}, remove() {}, toggle() {} } },
|
||||
};
|
||||
// Posé désactivé par forgeShowQuizBox dès la question (jamais recréé
|
||||
// par forgeQuizBoxAnswer, qui se contente de .disabled=false) — voir
|
||||
// les tests dédiés plus bas. Un objet à part (pas dérivé de
|
||||
// roles.choices.innerHTML, une simple chaîne dans ce mock) pour
|
||||
// pouvoir observer .disabled/.textContent comme un vrai élément DOM.
|
||||
const continueBtn = { disabled: true, textContent: 'Continuer →' };
|
||||
const classes = new Set();
|
||||
// querySelectorAll('.quizBoxChoiceBtn') n'a pas besoin de refléter le
|
||||
// HTML réellement injecté dans roles.choices.innerHTML (voir
|
||||
// forgeShowQuizBox) pour ces tests — juste le bon NOMBRE de boutons,
|
||||
// marqués correct/wrong par forgeQuizBoxAnswer via ces mêmes objets.
|
||||
const buttons = Array.from({ length: choiceCount }, () => fakeQuizChoiceBtn());
|
||||
return {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? String(objectId) : null),
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
const widget = {
|
||||
parentNode: null,
|
||||
// Simule ".closest('.playScreen')" (voir forgeEscapeCameraForFullscreen) :
|
||||
// un widget "posé sur l'écran courant" est toujours, au départ,
|
||||
// l'enfant d'un ".playScreen" fictif portant le même id d'écran.
|
||||
closest: sel => (sel === '.playScreen' ? { getAttribute: a => (a === 'data-screen-id' ? String(window.currentScreenId) : null) } : null),
|
||||
getAttribute: attr => {
|
||||
if (attr === 'data-object-id') return String(objectId);
|
||||
if (attr in attrs) return attrs[attr];
|
||||
return null;
|
||||
},
|
||||
setAttribute: (k, v) => { attrs[k] = v; },
|
||||
classList: {
|
||||
// Variadique (comme le vrai DOMTokenList.remove/add) : le moteur
|
||||
// appelle parfois .remove('is-active', 'is-fullscreen') d'un coup
|
||||
// (voir forgeQuizResultQuit/forgeHideQuizBoxOnCurrentScreen).
|
||||
add: (...cs) => cs.forEach(c => classes.add(c)), remove: (...cs) => cs.forEach(c => classes.delete(c)),
|
||||
contains: c => classes.has(c),
|
||||
toggle: (c, force) => { const on = force === undefined ? !classes.has(c) : !!force; if (on) classes.add(c); else classes.delete(c); return on; },
|
||||
},
|
||||
querySelector: sel => {
|
||||
if (sel === '[data-quiz-role="header"]') return roles.header;
|
||||
if (sel === '[data-quiz-role="question"]') return roles.question;
|
||||
if (sel === '[data-quiz-role="choices"]') return roles.choices;
|
||||
if (sel === '[data-quiz-role="score"]') return roles.score;
|
||||
if (sel === '[data-quiz-role="timer"]') return roles.timer;
|
||||
if (sel === '.quizBoxContinueBtn') return continueBtn;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: sel => (sel === '.quizBoxChoiceBtn' ? buttons : []),
|
||||
offsetWidth: 100,
|
||||
_roles: roles, _classes: classes, _buttons: buttons,
|
||||
_roles: roles, _classes: classes, _buttons: buttons, _continueBtn: continueBtn,
|
||||
};
|
||||
return widget;
|
||||
}
|
||||
|
||||
function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
@@ -260,7 +334,18 @@ function docWithDialogueAndQuiz(dialogueWidget, quizWidget) {
|
||||
if (sel.indexOf('data-object-id="42"') !== -1) return dialogueWidget; // forgeDialogueBoxWidgetById
|
||||
return null;
|
||||
},
|
||||
querySelectorAll: () => [], // aucun widget "🏆 Score" dans ces tests
|
||||
querySelectorAll(sel) {
|
||||
// aucun widget "🏆 Score" dans ces tests — seul le score AFFICHÉ
|
||||
// DANS la boîte à quiz elle-même (voir forgeUpdateQuizBoxScoreDisplays)
|
||||
// a besoin d'être résolu ici, via le rôle déjà exposé par
|
||||
// fakeQuizWidget ci-dessus.
|
||||
if (sel === '.quizBoxWidget [data-quiz-role="score"]') return quizWidget ? [quizWidget._roles.score] : [];
|
||||
return [];
|
||||
},
|
||||
// Nécessaire pour un quiz PLEIN ÉCRAN (voir forgeEscapeCameraForFullscreen) :
|
||||
// pas besoin de simuler un vrai retrait/réinsertion DOM pour ces
|
||||
// tests, juste que l'appel ne plante pas.
|
||||
body: { appendChild: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -274,6 +359,197 @@ test.beforeEach(() => {
|
||||
window.forgeQuizScore = 0;
|
||||
});
|
||||
|
||||
// Bug signalé : "j'ai créé un quiz à l'affichage de l'écran avec l'ia,
|
||||
// mais quand je fais jouer rien ne s'affiche" — un quiz RH AUTONOME (que
|
||||
// des questions, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de
|
||||
// dialogue" posée, seulement d'une "❓ Boîte à quiz" ; forgeShowDialogueBox
|
||||
// sortait pourtant en silence dès le départ faute de boîte de dialogue,
|
||||
// empêchant même la première question de s'afficher.
|
||||
function docWithQuizOnlyNoDialogueBox(quizWidget) {
|
||||
return {
|
||||
querySelector(sel) {
|
||||
if (sel.indexOf('.quizBoxWidget') !== -1 && sel.indexOf('data-object-id') === -1) return quizWidget || null;
|
||||
if (sel.indexOf('data-object-id="99"') !== -1) return quizWidget || null;
|
||||
return null; // jamais de ".dialogueBoxWidget" sur cet écran
|
||||
},
|
||||
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizWidget._roles.score] : []),
|
||||
body: { appendChild: () => {} },
|
||||
};
|
||||
}
|
||||
|
||||
test('un quiz SANS aucune "💬 Boîte de dialogue" posée (que des questions) affiche bien la première question', () => {
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_30', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
]);
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
assert.equal(quizWidget._roles.question.textContent, 'Qui es-tu ?');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('un quiz SANS boîte de dialogue : répondre avance/termine correctement (markCompleted inclus)', () => {
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_31', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
], true); // markCompleted
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse
|
||||
assert.equal(window.forgeQuizScore, 1);
|
||||
forgeQuizBoxContinue(99); // -> plus aucune ligne, dialogue épuisé
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_31'], true);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
// ---------- Écran de résultat de fin de quiz ("classique" uniquement,
|
||||
// voir static/js/play/quiz-box-templates.js::forgeQuizClassiqueOnQuizComplete)
|
||||
// demande explicite : "la page de resultat est cassee... elle affiche un
|
||||
// conteneur qui reaffiche le contenu du quiz au lieu du resultat" — le
|
||||
// vrai moteur n'en avait aucun jusqu'ici. `forgeQuizTemplateEffects` est
|
||||
// une variable GLOBALE (voir quiz-box-templates.js, jamais requise ici) :
|
||||
// simulée au cas par cas, jamais définie par défaut — donc TOUS les
|
||||
// tests ci-dessus (aucun modèle enregistré) continuent de suivre
|
||||
// l'ancien chemin (masquage simple), inchangé. ----------
|
||||
|
||||
function withFakeTemplateEffects(effects, fn) {
|
||||
const previous = global.forgeQuizTemplateEffects;
|
||||
global.forgeQuizTemplateEffects = effects;
|
||||
try { fn(); } finally { global.forgeQuizTemplateEffects = previous; }
|
||||
}
|
||||
|
||||
test('forgeQuizBoxContinue à la DERNIÈRE question d\'un modèle avec écran de résultat : reste actif/plein écran, appelle onQuizComplete avec le score DE CE QUIZ', () => {
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const previousDoc = global.document;
|
||||
let completeCalledWith = null;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_50', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0); // bonne réponse — seule et dernière question
|
||||
forgeQuizBoxContinue(99);
|
||||
assert.equal(quizWidget._classes.has('is-active'), true, 'reste visible pour montrer le résultat, pas masqué');
|
||||
assert.equal(quizWidget._classes.has('is-fullscreen'), true);
|
||||
assert.deepEqual(completeCalledWith, { correctCount: 1, totalCount: 1 });
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeQuizBoxContinue AVANT la dernière question : jamais d\'écran de résultat, même avec un modèle qui en propose un', () => {
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const previousDoc = global.document;
|
||||
let completeCalled = false;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => { completeCalled = true; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_51', 'Quiz', [
|
||||
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99); // avance vers la question 2, pas la fin
|
||||
assert.equal(completeCalled, false);
|
||||
assert.equal(quizWidget._roles.question.textContent, 'Question 2 ?');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeQuizResultQuit (bouton de l\'écran de résultat) masque le widget PUIS tente de fermer l\'onglet — demande explicite : quitter carrément, jamais recommencer', () => {
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const previousDoc = global.document;
|
||||
const previousClose = window.close;
|
||||
let closeCalled = false;
|
||||
window.close = () => { closeCalled = true; };
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_52', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99); // -> écran de résultat, widget toujours actif
|
||||
forgeQuizResultQuit(99);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
|
||||
// window.close() ne ferme RÉELLEMENT que les onglets ouverts par
|
||||
// script (restriction navigateur, voir le commentaire dans
|
||||
// dialogue-box-controller.js) — on ne peut donc tester ici que
|
||||
// "l'appel a bien été tenté", jamais qu'un onglet a vraiment fermé.
|
||||
assert.equal(closeCalled, true);
|
||||
} finally { global.document = previousDoc; window.close = previousClose; }
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeQuizResultQuit ne plante jamais si window.close n\'existe pas (navigateur qui ne l\'expose pas)', () => {
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const previousDoc = global.document;
|
||||
const previousClose = window.close;
|
||||
delete window.close;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: () => {} } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_54', 'Quiz', [
|
||||
{ type: 'question', text: 'Qui es-tu ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
]);
|
||||
forgeQuizBoxAnswer(99, 0);
|
||||
forgeQuizBoxContinue(99);
|
||||
assert.doesNotThrow(() => forgeQuizResultQuit(99));
|
||||
} finally { global.document = previousDoc; window.close = previousClose; }
|
||||
});
|
||||
});
|
||||
|
||||
test('forgeQuizResultQuit ne fait rien sans widget correspondant', () => {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => null };
|
||||
try {
|
||||
assert.doesNotThrow(() => forgeQuizResultQuit(999999));
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeQuizBoxTimeout (minuteur du quiz ENTIER expiré) affiche l\'écran de résultat même s\'il restait des questions', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5', 'data-fullscreen': '1', 'data-quiz-template': 'classique' });
|
||||
const previousDoc = global.document;
|
||||
let completeCalledWith = null;
|
||||
global.document = docWithQuizOnlyNoDialogueBox(quizWidget);
|
||||
withFakeTemplateEffects({ classique: { onQuizComplete: (w, result) => { completeCalledWith = result; } } }, () => {
|
||||
try {
|
||||
forgeShowDialogueBox('d_53', 'Quiz', [
|
||||
{ type: 'question', text: 'Question 1 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
{ type: 'question', text: 'Question 2 ?', choices: ['A', 'B'], correct_index: 0, reward_amount: 1 },
|
||||
]);
|
||||
forgeQuizBoxTimeout(99, true); // encore la question 2 à venir
|
||||
t.mock.timers.tick(1500);
|
||||
assert.deepEqual(completeCalledWith, { correctCount: 0, totalCount: 1 });
|
||||
assert.equal(quizWidget._classes.has('is-active'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
});
|
||||
|
||||
test('"Continuer →" est TOUJOURS présent dès la question (désactivé), jamais recréé — juste activé par une réponse (demande explicite, comme la maquette manga)', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99, 2);
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_60', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question
|
||||
// Dès l'affichage de la question, AVANT toute réponse : présent
|
||||
// dans le balisage des choix ET désactivé.
|
||||
assert.match(quizWidget._roles.choices.innerHTML, /forgeQuizBoxContinue\(99\)/);
|
||||
assert.equal(quizWidget._continueBtn.disabled, true);
|
||||
forgeQuizBoxAnswer(99, 1); // bonne réponse (correct_index=1)
|
||||
// Le MÊME objet, juste activé — jamais un nouveau bouton recréé.
|
||||
assert.equal(quizWidget._continueBtn.disabled, false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('une ligne "question" est affichée dans la boîte à QUIZ (header = nom de l\'objet déclencheur, corps = question+choix), pas la boîte de dialogue', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
dialogueWidget.getAttribute = attr => (attr === 'data-object-id' ? '42' : null);
|
||||
@@ -372,11 +648,120 @@ test('aucune boîte à quiz posée sur la scène : la question est ignorée, la
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
// ---------- Plein écran + score affiché (demande explicite) ----------
|
||||
|
||||
test('forgeShowQuizBox pose ".is-fullscreen" quand data-fullscreen="1", jamais sinon', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidgetOn = fakeQuizWidget(99, 2, { 'data-fullscreen': '1', 'data-timer-mode': 'aucun' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidgetOn);
|
||||
try {
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.equal(quizWidgetOn._classes.has('is-fullscreen'), true);
|
||||
} finally { global.document = previousDoc; }
|
||||
|
||||
const quizWidgetOff = fakeQuizWidget(99, 2, { 'data-fullscreen': '0', 'data-timer-mode': 'aucun' });
|
||||
global.document = docWithDialogueAndQuiz(fakeWidget(42), quizWidgetOff);
|
||||
try {
|
||||
forgeShowDialogueBox('d_11', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.equal(quizWidgetOff._classes.has('is-fullscreen'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('le score affiché DANS la boîte à quiz (data-quiz-role="score") se met à jour à chaque question et bonne réponse', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'aucun' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
assert.equal(quizWidget._roles.score.textContent, 'Score : 0');
|
||||
forgeQuizBoxAnswer(99, 1); // bonne réponse, +5
|
||||
assert.equal(quizWidget._roles.score.textContent, 'Score : 5');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
// ---------- Minuteur (optionnel, "aucun" par défaut — demande explicite :
|
||||
// "pouvoir paramétrer un timer pour répondre aux questions") ----------
|
||||
|
||||
test('minuteur "par question" expiré : révèle la bonne réponse (aucun bouton marqué faux), puis "Continuer →" fonctionne normalement', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur (10s)
|
||||
t.mock.timers.tick(10000); // le minuteur expire tout seul
|
||||
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true); // la bonne réponse (index 1) révélée
|
||||
assert.equal(quizWidget._buttons[0]._classes.has('is-wrong'), false); // aucun clic -> aucun bouton "faux"
|
||||
assert.equal(window.forgeQuizScore, 0); // comptée fausse, aucun point
|
||||
assert.equal(window.forgeQuizTotalAnswered, 1);
|
||||
forgeQuizBoxContinue(99); // "Continuer →" fonctionne normalement après un minuteur expiré
|
||||
assert.equal(dialogueWidget._roles.body.textContent, 'Bien joué !');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('un minuteur "par question" toujours en cours est arrêté dès qu\'on répond (jamais de double expiration)', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeQuizBoxAnswer(99, 1); // répond avant l'expiration
|
||||
assert.equal(window.forgeQuizScore, 5);
|
||||
t.mock.timers.tick(10000); // le minuteur, s'il tournait encore, exploserait ici
|
||||
assert.equal(window.forgeQuizTotalAnswered, 1); // toujours une seule réponse comptée
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('minuteur "pour tout le quiz" expiré : révèle la bonne réponse PUIS termine le quiz tout seul (markCompleted/thenAction inclus)', (t) => {
|
||||
t.mock.timers.enable({ apis: ['setInterval', 'setTimeout'] });
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'quiz', 'data-timer-seconds': '5' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_20', 'Le sorcier', QUIZ_LINES, true); // markCompleted=true
|
||||
forgeDialogueBoxAdvance(42); // -> question, démarre le minuteur "quiz" (5s)
|
||||
t.mock.timers.tick(5000); // expire : révèle la bonne réponse
|
||||
assert.equal(quizWidget._buttons[1]._classes.has('is-correct'), true);
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], undefined); // pas encore, le délai de lecture tourne
|
||||
t.mock.timers.tick(1500); // délai de lecture écoulé -> fin forcée du quiz
|
||||
assert.equal(window.forgeCompletedDialogueIds['d_20'], true);
|
||||
assert.equal(quizWidget._classes.has('is-active'), false);
|
||||
assert.equal(quizWidget._classes.has('is-fullscreen'), false);
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeQuizBoxTimeout n\'a aucun effet si la question est déjà résolue (pas de double révélation)', () => {
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = fakeQuizWidget(99, 2, { 'data-timer-mode': 'question', 'data-timer-seconds': '10' });
|
||||
const previousDoc = global.document;
|
||||
global.document = docWithDialogueAndQuiz(dialogueWidget, quizWidget);
|
||||
try {
|
||||
forgeShowDialogueBox('d_10', 'Le sorcier', QUIZ_LINES);
|
||||
forgeDialogueBoxAdvance(42);
|
||||
forgeQuizBoxAnswer(99, 1); // déjà répondu
|
||||
assert.doesNotThrow(() => forgeQuizBoxTimeout(99, false));
|
||||
assert.equal(window.forgeQuizTotalAnswered, 1); // pas compté deux fois
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" affichés', () => {
|
||||
const values = [{ textContent: '' }, { textContent: '' }];
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 42;
|
||||
global.document = { querySelectorAll: () => values };
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.scoreWidget [data-score-role="value"]' ? values : []),
|
||||
};
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
assert.equal(values[0].textContent, 42);
|
||||
@@ -384,6 +769,22 @@ test('forgeUpdateAllScoreWidgets — met à jour tous les widgets "🏆 Score" a
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeUpdateAllScoreWidgets — met aussi à jour le score affiché DANS une boîte à quiz plein écran', () => {
|
||||
// Demande explicite : "lorsque la boîte à quiz prend tout l'écran, le
|
||||
// score s'affiche dans la boîte à quiz" — voir
|
||||
// forgeUpdateQuizBoxScoreDisplays, appelée par forgeUpdateAllScoreWidgets.
|
||||
const quizScoreEl = { textContent: '' };
|
||||
const previousDoc = global.document;
|
||||
window.forgeQuizScore = 7;
|
||||
global.document = {
|
||||
querySelectorAll: sel => (sel === '.quizBoxWidget [data-quiz-role="score"]' ? [quizScoreEl] : []),
|
||||
};
|
||||
try {
|
||||
forgeUpdateAllScoreWidgets();
|
||||
assert.equal(quizScoreEl.textContent, 'Score : 7');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
// ---------- Pont vers SCORM (gameData.scoring, voir static/js/play/offline/scorm-api.js) ----------
|
||||
// Bug signalé : "les indicateurs qu'on utilise pour les dialogues
|
||||
// devraient aussi servir aux indicateurs SCORM, or ce n'est pas le cas" —
|
||||
@@ -500,7 +901,7 @@ test('répondre à une question notifie xAPI avec la bonne/mauvaise réponse et
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
@@ -527,7 +928,7 @@ test('répondre à une question notifie AUSSI le SCORM 2004 (cmi.interactions.n.
|
||||
const dialogueWidget = fakeWidget(42);
|
||||
const quizWidget = {
|
||||
getAttribute: attr => (attr === 'data-object-id' ? '99' : null),
|
||||
classList: { add: () => {}, remove: () => {} },
|
||||
classList: { add: () => {}, remove: () => {}, toggle: () => {} },
|
||||
querySelector: sel => (sel.indexOf('choices') !== -1 ? { innerHTML: '' } : { textContent: '' }),
|
||||
querySelectorAll: () => [],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Effets visuels par modèle de "❓ Boîte à quiz" (voir static/js/play/
|
||||
// quiz-box-templates.js, appelé par dialogue-box-controller.js aux
|
||||
// points d'extension du moteur partagé).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
global.window = global.window || {};
|
||||
global.document = global.document || {
|
||||
createElement() { return { style: {}, className: '', appendChild() {} }; },
|
||||
};
|
||||
|
||||
const {
|
||||
forgeQuizTemplateEffects,
|
||||
forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
|
||||
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
|
||||
} = require('../quiz-box-templates.js');
|
||||
|
||||
test('les 4 modèles sont bien enregistrés ("defaut"/"manga_dialogue" boîte de dialogue + "classique"/"manga" page de quiz)', () => {
|
||||
assert.deepEqual(
|
||||
Object.keys(forgeQuizTemplateEffects).sort(),
|
||||
['classique', 'defaut', 'manga', 'manga_dialogue'],
|
||||
);
|
||||
});
|
||||
|
||||
test('le registre ne branche aucun effet pour les modèles "boîte de dialogue" ("defaut"/"manga_dialogue" restent proches du modèle de base, décoration CSS uniquement)', () => {
|
||||
assert.deepEqual(forgeQuizTemplateEffects.defaut, {});
|
||||
assert.deepEqual(forgeQuizTemplateEffects.manga_dialogue, {});
|
||||
});
|
||||
|
||||
function fakeMangaWidget() {
|
||||
const header = { textContent: 'PNJ' };
|
||||
const progress = { innerHTML: '' };
|
||||
const resultZone = { innerHTML: '' };
|
||||
const classes = new Set();
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
querySelector(sel) {
|
||||
if (sel === '[data-quiz-role="header"]') return header;
|
||||
if (sel === '[data-manga-role="progress"]') return progress;
|
||||
if (sel === '[data-manga-role="result"]') return resultZone;
|
||||
return null;
|
||||
},
|
||||
classList: { add: c => classes.add(c), remove: c => classes.delete(c), contains: c => classes.has(c) },
|
||||
_header: header, _progress: progress, _resultZone: resultZone, _classes: classes,
|
||||
};
|
||||
}
|
||||
|
||||
test('forgeQuizMangaOnShow ("manga", onShow) affiche "Question N/M" (uniquement les lignes de type question) et une barre d\'étapes', () => {
|
||||
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
|
||||
global.forgeDialogueBoxState = {
|
||||
7: {
|
||||
index: 3, // 0:dialogue 1:question 2:dialogue 3:question(courante) 4:question
|
||||
lines: [
|
||||
{ type: 'dialogue' },
|
||||
{ type: 'question' },
|
||||
{ type: 'dialogue' },
|
||||
{ type: 'question' },
|
||||
{ type: 'question' },
|
||||
],
|
||||
},
|
||||
};
|
||||
const widget = fakeMangaWidget();
|
||||
forgeQuizMangaOnShow(widget);
|
||||
assert.equal(widget._header.textContent, 'Question 2/3');
|
||||
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i><i class="is-current"></i><i class=""></i>');
|
||||
delete global.forgeQuizBoxState;
|
||||
delete global.forgeDialogueBoxState;
|
||||
});
|
||||
|
||||
test('forgeQuizMangaOnShow ne plante jamais sans widget/état', () => {
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnShow(null));
|
||||
delete global.forgeQuizBoxState;
|
||||
delete global.forgeDialogueBoxState;
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget()));
|
||||
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnShow(fakeMangaWidget())); // pas de forgeDialogueBoxState
|
||||
delete global.forgeQuizBoxState;
|
||||
});
|
||||
|
||||
test('forgeQuizMangaOnShow ne touche à rien si le dialogue ne contient aucune question (total === 0)', () => {
|
||||
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
|
||||
global.forgeDialogueBoxState = { 7: { index: 0, lines: [{ type: 'dialogue' }] } };
|
||||
const widget = fakeMangaWidget();
|
||||
forgeQuizMangaOnShow(widget);
|
||||
assert.equal(widget._header.textContent, 'PNJ');
|
||||
assert.equal(widget._progress.innerHTML, '');
|
||||
delete global.forgeQuizBoxState;
|
||||
delete global.forgeDialogueBoxState;
|
||||
});
|
||||
|
||||
test('forgeQuizMangaOnShow retire "is-showing-result" (défensif — widget réutilisé pour un quiz déclenché à nouveau)', () => {
|
||||
const widget = fakeMangaWidget();
|
||||
widget.classList.add('is-showing-result');
|
||||
forgeQuizMangaOnShow(widget);
|
||||
assert.equal(widget._classes.has('is-showing-result'), false);
|
||||
});
|
||||
|
||||
test('forgeQuizMangaOnQuizComplete remplit l\'écran de résultat manga (badge 合格/不合格), remplit la barre d\'étapes à 100% et bascule la RACINE du widget en "is-showing-result"', () => {
|
||||
const widget = fakeMangaWidget();
|
||||
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
|
||||
try {
|
||||
forgeQuizMangaOnQuizComplete(widget, { correctCount: 4, totalCount: 5 });
|
||||
assert.equal(widget._classes.has('is-showing-result'), true);
|
||||
assert.match(widget._resultZone.innerHTML, /Score final : 4 \/ 5/);
|
||||
assert.match(widget._resultZone.innerHTML, /mangaResultBadge pass/);
|
||||
assert.match(widget._resultZone.innerHTML, /合格/);
|
||||
assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/);
|
||||
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i>'.repeat(5));
|
||||
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
|
||||
});
|
||||
|
||||
test('forgeQuizMangaOnQuizComplete affiche le badge "不合格" (échoué) sous le seuil de réussite', () => {
|
||||
const widget = fakeMangaWidget();
|
||||
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
|
||||
try {
|
||||
forgeQuizMangaOnQuizComplete(widget, { correctCount: 1, totalCount: 5 });
|
||||
assert.match(widget._resultZone.innerHTML, /mangaResultBadge fail/);
|
||||
assert.match(widget._resultZone.innerHTML, /不合格/);
|
||||
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
|
||||
});
|
||||
|
||||
test('forgeQuizMangaOnQuizComplete ne plante jamais sans widget/sans le conteneur de résultat', () => {
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(() => forgeQuizMangaOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
|
||||
});
|
||||
|
||||
// "classique" réutilise le même calcul générique (forgeQuizProgressOnShow)
|
||||
// que "manga", juste avec son propre sélecteur de barre d'étapes
|
||||
// (data-classique-role, jamais data-manga-role).
|
||||
function fakeClassiqueWidget() {
|
||||
const header = { textContent: 'PNJ' };
|
||||
const progress = { innerHTML: '' };
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
querySelector(sel) {
|
||||
if (sel === '[data-quiz-role="header"]') return header;
|
||||
if (sel === '[data-classique-role="progress"]') return progress;
|
||||
return null;
|
||||
},
|
||||
_header: header, _progress: progress,
|
||||
};
|
||||
}
|
||||
|
||||
test('forgeQuizClassiqueOnShow ("classique", onShow) affiche "Question N/M" et une barre d\'étapes, comme "manga"', () => {
|
||||
global.forgeQuizBoxState = { 42: { dialogueObjectId: 7 } };
|
||||
global.forgeDialogueBoxState = {
|
||||
7: { index: 1, lines: [{ type: 'question' }, { type: 'question' }] },
|
||||
};
|
||||
const widget = fakeClassiqueWidget();
|
||||
forgeQuizClassiqueOnShow(widget);
|
||||
assert.equal(widget._header.textContent, 'Question 2/2');
|
||||
assert.equal(widget._progress.innerHTML, '<i class="is-done"></i><i class="is-current"></i>');
|
||||
delete global.forgeQuizBoxState;
|
||||
delete global.forgeDialogueBoxState;
|
||||
});
|
||||
|
||||
test('forgeQuizClassiqueOnShow ne plante jamais sans widget/état', () => {
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnShow(null));
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnShow(fakeClassiqueWidget()));
|
||||
});
|
||||
|
||||
// ---------- Écran de résultat ("classique" uniquement) ----------
|
||||
|
||||
function fakeClassiqueWidgetForResult() {
|
||||
const contentClasses = new Set();
|
||||
const content = { classList: { add: c => contentClasses.add(c), remove: c => contentClasses.delete(c), contains: c => contentClasses.has(c) } };
|
||||
const resultZone = { innerHTML: '' };
|
||||
const headerTitle = { textContent: 'Formation Sécurité Incendie' };
|
||||
return {
|
||||
getAttribute(attr) { return attr === 'data-object-id' ? '42' : null; },
|
||||
querySelector(sel) {
|
||||
if (sel === '.classiqueContent') return content;
|
||||
if (sel === '[data-classique-role="result"]') return resultZone;
|
||||
if (sel === '.classiqueHeaderTitle') return headerTitle;
|
||||
return null;
|
||||
},
|
||||
_content: content, _contentClasses: contentClasses, _resultZone: resultZone, _headerTitle: headerTitle,
|
||||
};
|
||||
}
|
||||
|
||||
test('forgeQuizClassiqueOnQuizComplete remplit l\'écran de résultat (score, badge réussi) et bascule .classiqueContent en "is-showing-result"', () => {
|
||||
const widget = fakeClassiqueWidgetForResult();
|
||||
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
|
||||
try {
|
||||
forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 3, totalCount: 4 });
|
||||
assert.equal(widget._contentClasses.has('is-showing-result'), true);
|
||||
assert.match(widget._resultZone.innerHTML, /75%/);
|
||||
assert.match(widget._resultZone.innerHTML, /3 bonnes réponses sur 4/);
|
||||
assert.match(widget._resultZone.innerHTML, /Formation Sécurité Incendie/);
|
||||
assert.match(widget._resultZone.innerHTML, /classiqueResultBadge pass/);
|
||||
assert.match(widget._resultZone.innerHTML, /Module validé/);
|
||||
assert.match(widget._resultZone.innerHTML, /forgeQuizResultQuit\(42\)/);
|
||||
assert.match(widget._resultZone.innerHTML, /Quitter le quiz/);
|
||||
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
|
||||
});
|
||||
|
||||
test('forgeQuizClassiqueOnQuizComplete affiche le badge "échoué" sous le seuil de réussite', () => {
|
||||
const widget = fakeClassiqueWidgetForResult();
|
||||
const previousThreshold = window.FORGE_SUCCESS_THRESHOLD_PERCENT;
|
||||
window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;
|
||||
try {
|
||||
forgeQuizClassiqueOnQuizComplete(widget, { correctCount: 2, totalCount: 4 });
|
||||
assert.match(widget._resultZone.innerHTML, /50%/);
|
||||
assert.match(widget._resultZone.innerHTML, /classiqueResultBadge fail/);
|
||||
assert.match(widget._resultZone.innerHTML, /Module non validé — seuil 70%/);
|
||||
} finally { window.FORGE_SUCCESS_THRESHOLD_PERCENT = previousThreshold; }
|
||||
});
|
||||
|
||||
test('forgeQuizClassiqueOnQuizComplete ne plante jamais sans widget/sans les éléments attendus', () => {
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete(null, { correctCount: 1, totalCount: 1 }));
|
||||
assert.doesNotThrow(() => forgeQuizClassiqueOnQuizComplete({ querySelector: () => null }, { correctCount: 1, totalCount: 1 }));
|
||||
});
|
||||
|
||||
test('forgeQuizClassiqueOnShow retire "is-showing-result" (défensif — un futur modèle "Recommencer" réutiliserait ce garde-fou)', () => {
|
||||
const widget = fakeClassiqueWidgetForResult();
|
||||
widget._content.classList.add('is-showing-result');
|
||||
// fakeClassiqueWidgetForResult n'a pas les rôles attendus par
|
||||
// forgeQuizProgressOnShow (header/progress) : seul le retrait de la
|
||||
// classe nous intéresse ici, le reste no-op sans état global posé.
|
||||
forgeQuizClassiqueOnShow(widget);
|
||||
assert.equal(widget._contentClasses.has('is-showing-result'), false);
|
||||
});
|
||||
@@ -110,16 +110,93 @@ function forgeResetDialogueBoxFooter(widget, objectId) {
|
||||
// ---------- Boîte à quiz ----------
|
||||
|
||||
function forgeQuizBoxWidgetOnCurrentScreen() {
|
||||
return document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||
var scoped = document.querySelector('.playScreen[data-screen-id="' + window.currentScreenId + '"] .quizBoxWidget');
|
||||
if (scoped) return scoped;
|
||||
// Un quiz PLEIN ÉCRAN a été déplacé hors de ".playScreen" (voir
|
||||
// forgeEscapeCameraForFullscreen ci-dessous) : ne s'y trouve donc plus
|
||||
// une fois la première question affichée — retrouvé via l'attribut
|
||||
// posé au déplacement plutôt que par sa position dans le DOM.
|
||||
return document.querySelector('.quizBoxWidget[data-owner-screen-id="' + window.currentScreenId + '"]');
|
||||
}
|
||||
|
||||
function forgeQuizBoxWidgetById(objectId) {
|
||||
return document.querySelector('.quizBoxWidget[data-object-id="' + objectId + '"]');
|
||||
}
|
||||
|
||||
// Bug signalé : "aucune différence, la taille de la fenêtre de jeu est
|
||||
// la même alors qu'il n'y a plus de caméra" — ".playScreen.playScene"
|
||||
// (templates/play.html) est centré via `transform:translate(-50%,-50%)`,
|
||||
// ce qui en fait la référence de positionnement ("containing block") de
|
||||
// tout descendant `position:fixed` : le plein écran de la boîte à quiz
|
||||
// ne couvrait donc que CE conteneur (toujours à la taille scene_width/
|
||||
// scene_height, la "caméra"), jamais le VRAI viewport du navigateur.
|
||||
// Seul un déplacement hors de ce conteneur (comme static/js/play/
|
||||
// video-overlay.js le fait déjà pour son overlay) permet à
|
||||
// `position:fixed` de couvrir tout l'écran pour de vrai. `data-owner-
|
||||
// screen-id` (posé ici) permet à forgeQuizBoxWidgetOnCurrentScreen ci-
|
||||
// dessus de le retrouver malgré le déplacement.
|
||||
function forgeEscapeCameraForFullscreen(widget) {
|
||||
if (widget.parentNode === document.body) return;
|
||||
var screenEl = widget.closest('.playScreen');
|
||||
if (screenEl) widget.setAttribute('data-owner-screen-id', screenEl.getAttribute('data-screen-id'));
|
||||
widget.classList.add('quizBoxWidget--detachedForFullscreen');
|
||||
document.body.appendChild(widget);
|
||||
}
|
||||
|
||||
// Point de sortie UNIQUE de la boîte à quiz (fin d'une question réussie
|
||||
// vers la ligne suivante, OU fin de tout le dialogue) — y arrêter aussi
|
||||
// le minuteur/retirer le plein écran ici garantit qu'aucun des deux
|
||||
// n'est jamais laissé actif par erreur, quel que soit le chemin de
|
||||
// sortie (voir forgeShowCurrentConversationLine/forgeAdvanceConversation).
|
||||
function forgeHideQuizBoxOnCurrentScreen() {
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
if (!widget) return;
|
||||
widget.classList.remove('is-active', 'is-fullscreen');
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
forgeStopQuizTimer(objectId);
|
||||
}
|
||||
|
||||
// ---------- Minuteur (plein écran, demande explicite : "un timer pour
|
||||
// répondre aux questions") ----------
|
||||
// Optionnel — "aucun" (screens/rendering/quiz_box_config.py) est le
|
||||
// réglage par défaut, jamais imposé. Porté par data-timer-mode/
|
||||
// data-timer-seconds sur le widget lui-même (voir screens/rendering/
|
||||
// quiz_box_templates.py), lus ici plutôt que par un nouveau canal de
|
||||
// payload séparé — cohérent avec le reste de ce moteur (data-* posés au
|
||||
// rendu, jamais un aller-retour serveur pour du pur affichage).
|
||||
var forgeQuizTimerState = {};
|
||||
|
||||
function forgeStopQuizTimer(objectId) {
|
||||
var t = forgeQuizTimerState[objectId];
|
||||
if (t && t.intervalId) clearInterval(t.intervalId);
|
||||
delete forgeQuizTimerState[objectId];
|
||||
}
|
||||
|
||||
// mode "question" : redémarre à CHAQUE question (reset=true, appelé
|
||||
// depuis forgeShowQuizBox). mode "quiz" : démarré UNE SEULE FOIS pour
|
||||
// tout le quiz (reset=false une fois déjà en cours), continue de tourner
|
||||
// d'une question à l'autre sans se réinitialiser.
|
||||
function forgeStartQuizTimer(objectId, widget, seconds, onExpire) {
|
||||
forgeStopQuizTimer(objectId);
|
||||
var state = { remaining: seconds };
|
||||
forgeQuizTimerState[objectId] = state;
|
||||
var timerEl = widget.querySelector('[data-quiz-role="timer"]');
|
||||
function render() {
|
||||
if (!timerEl) return;
|
||||
var remaining = Math.max(0, state.remaining);
|
||||
var m = Math.floor(remaining / 60), s = remaining % 60;
|
||||
timerEl.textContent = '⏱ ' + m + ':' + (s < 10 ? '0' : '') + s;
|
||||
timerEl.classList.toggle('is-urgent', remaining <= 5);
|
||||
}
|
||||
render();
|
||||
state.intervalId = setInterval(function () {
|
||||
state.remaining -= 1;
|
||||
render();
|
||||
if (state.remaining <= 0) {
|
||||
forgeStopQuizTimer(objectId);
|
||||
onExpire();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ---------- Score ----------
|
||||
@@ -127,6 +204,17 @@ function forgeHideQuizBoxOnCurrentScreen() {
|
||||
function forgeUpdateAllScoreWidgets() {
|
||||
var values = document.querySelectorAll('.scoreWidget [data-score-role="value"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = window.forgeQuizScore;
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
}
|
||||
|
||||
// Score affiché DANS la boîte à quiz elle-même (demande explicite :
|
||||
// "lorsque la boîte à quiz prend tout l'écran, le score s'affiche dans
|
||||
// la boîte à quiz") — masqué par CSS hors plein écran (voir
|
||||
// .quizBoxTopBar, static/style.css), donc sans effet visuel pour un quiz
|
||||
// non-plein-écran, mais tenu à jour dans tous les cas (coûte rien).
|
||||
function forgeUpdateQuizBoxScoreDisplays() {
|
||||
var values = document.querySelectorAll('.quizBoxWidget [data-quiz-role="score"]');
|
||||
for (var i = 0; i < values.length; i++) values[i].textContent = 'Score : ' + window.forgeQuizScore;
|
||||
}
|
||||
|
||||
// Alimente aussi gameData.scoring (voir static/js/play/offline/
|
||||
@@ -237,9 +325,19 @@ function forgeShowCurrentConversationLine(dialogueObjectId) {
|
||||
function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, thenAction) {
|
||||
if (!dialogueId) return;
|
||||
lines = lines || [];
|
||||
// Bug corrigé : "j'ai créé un quiz à l'affichage de l'écran, rien ne
|
||||
// s'affiche" — un dialogue fait UNIQUEMENT de questions (quiz RH
|
||||
// autonome, aucune réplique) n'a besoin d'AUCUNE "💬 Boîte de dialogue"
|
||||
// posée sur l'écran, seulement d'une "❓ Boîte à quiz" — cette fonction
|
||||
// sortait pourtant en silence dès le départ s'il n'y avait aucune boîte
|
||||
// de dialogue, empêchant même la première question de s'afficher. Sans
|
||||
// widget réel pour porter la clé d'état, `dialogueId` sert de repli
|
||||
// (unique par action "Déclencher ce dialogue") — une ligne "réplique"
|
||||
// sans boîte de dialogue posée reste ignorée plus loin (voir
|
||||
// forgeShowCurrentConversationLine), même logique symétrique qu'une
|
||||
// question sans boîte à quiz posée (voir forgeShowQuizBox).
|
||||
var widget = forgeDialogueBoxWidgetOnCurrentScreen();
|
||||
if (!widget) return;
|
||||
var objectId = widget.getAttribute('data-object-id');
|
||||
var stateKey = widget ? widget.getAttribute('data-object-id') : ('_sansBoiteDialogue_' + dialogueId);
|
||||
forgeSyncDialogueStartedToScorm();
|
||||
if (!lines.length) {
|
||||
// Rien à jouer : applique quand même markCompleted PUIS thenAction
|
||||
@@ -248,11 +346,11 @@ function forgeShowDialogueBox(dialogueId, objectName, lines, markCompleted, then
|
||||
if (thenAction) forgeRunCollisionRuleAction(thenAction, null, { name: objectName });
|
||||
return;
|
||||
}
|
||||
forgeDialogueBoxState[objectId] = {
|
||||
forgeDialogueBoxState[stateKey] = {
|
||||
dialogueId: dialogueId, objectName: objectName, lines: lines, index: 0,
|
||||
markCompleted: !!markCompleted, thenAction: thenAction,
|
||||
};
|
||||
forgeShowCurrentConversationLine(objectId);
|
||||
forgeShowCurrentConversationLine(stateKey);
|
||||
}
|
||||
|
||||
// Marque un dialogue `completed` (RUNTIME uniquement, jamais persisté —
|
||||
@@ -308,9 +406,22 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
var widget = forgeQuizBoxWidgetOnCurrentScreen();
|
||||
if (!widget) { forgeAdvanceConversation(dialogueObjectId); return; }
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var previousState = forgeQuizBoxState[quizObjectId];
|
||||
forgeQuizBoxState[quizObjectId] = {
|
||||
dialogueObjectId: dialogueObjectId, correctIndex: line.correct_index, rewardAmount: line.reward_amount, resolved: false,
|
||||
dialogueId: dialogueId, objectName: objectName, questionIndex: questionIndex, questionText: line.text,
|
||||
// Minuteur "pour tout le quiz" (voir forgeStartQuizTimer plus bas) :
|
||||
// ne démarre qu'à la PREMIÈRE question, continue de tourner ensuite
|
||||
// sans se réinitialiser — ce drapeau survit d'une question à l'autre
|
||||
// via l'état précédent de CE MÊME quiz.
|
||||
quizTimerStarted: previousState ? previousState.quizTimerStarted : false,
|
||||
// Score DE CE QUIZ (jamais window.forgeQuizScore/forgeQuizCorrectAnswered,
|
||||
// cumulés sur TOUTE la partie, voir plus haut) — survit d'une
|
||||
// question à l'autre comme quizTimerStarted : sert à afficher "X
|
||||
// bonnes réponses sur Y" à la fin (voir forgeQuizBoxContinue/
|
||||
// forgeQuizPrepareResultScreen plus bas).
|
||||
correctCount: previousState ? previousState.correctCount : 0,
|
||||
answeredCount: previousState ? previousState.answeredCount : 0,
|
||||
};
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
var questionEl = widget.querySelector('[data-quiz-role="question"]');
|
||||
@@ -321,6 +432,33 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
choicesEl.innerHTML = line.choices.map(function (choice, i) {
|
||||
return '<button type="button" class="quizBoxChoiceBtn" onclick="forgeQuizBoxAnswer(' + quizObjectId + ', ' + i + ')">' + forgeEscapeHtml(choice) + '</button>';
|
||||
}).join('');
|
||||
// "Continuer →" TOUJOURS présent dès la question (demande explicite :
|
||||
// "le bouton continuer est toujours visible mais activé seulement
|
||||
// quand un choix est fait", comme la maquette manga validée) —
|
||||
// désactivé jusqu'à ce que forgeQuizBoxAnswer le réactive (jamais
|
||||
// recréé à ce moment-là, juste .disabled=false, voir plus bas).
|
||||
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')" disabled>Continuer →</button>';
|
||||
}
|
||||
// "Plein écran" (demande explicite) — reposé à CHAQUE question,
|
||||
// sans effet si déjà présent (voir .quizBoxWidget.is-fullscreen,
|
||||
// static/style.css). Échappe d'abord au conteneur de scène transformé
|
||||
// (voir forgeEscapeCameraForFullscreen) — sinon position:fixed reste
|
||||
// borné à sa taille "caméra" au lieu du vrai écran du joueur.
|
||||
var isFullscreen = widget.getAttribute('data-fullscreen') === '1';
|
||||
if (isFullscreen) forgeEscapeCameraForFullscreen(widget);
|
||||
widget.classList.toggle('is-fullscreen', isFullscreen);
|
||||
forgeUpdateQuizBoxScoreDisplays();
|
||||
// Minuteur (optionnel — "aucun" reste le réglage par défaut, jamais
|
||||
// imposé, voir screens/rendering/quiz_box_config.py).
|
||||
var timerMode = widget.getAttribute('data-timer-mode');
|
||||
var timerSeconds = parseInt(widget.getAttribute('data-timer-seconds'), 10) || 0;
|
||||
if (timerMode === 'question') {
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, false); });
|
||||
} else if (timerMode === 'quiz' && !forgeQuizBoxState[quizObjectId].quizTimerStarted) {
|
||||
forgeQuizBoxState[quizObjectId].quizTimerStarted = true;
|
||||
forgeStartQuizTimer(quizObjectId, widget, timerSeconds, function () { forgeQuizBoxTimeout(quizObjectId, true); });
|
||||
} else if (timerMode !== 'quiz') {
|
||||
forgeStopQuizTimer(quizObjectId);
|
||||
}
|
||||
// Rejoue l'animation d'entrée (@keyframes quizPopIn) même si la boîte
|
||||
// était déjà "is-active" juste avant (2 questions à la suite) : sans
|
||||
@@ -329,6 +467,18 @@ function forgeShowQuizBox(dialogueObjectId, dialogueId, objectName, line, questi
|
||||
widget.classList.remove('is-active');
|
||||
void widget.offsetWidth;
|
||||
widget.classList.add('is-active');
|
||||
// Effet d'entrée propre au modèle (voir static/js/play/
|
||||
// quiz-box-templates.js) — optionnel, "defaut" n'en a aucun.
|
||||
var showFx = forgeQuizTemplateEffectsFor(widget).onShow;
|
||||
if (showFx) showFx(widget);
|
||||
}
|
||||
|
||||
// Registre d'effets par modèle (voir static/js/play/quiz-box-
|
||||
// templates.js, chargé AVANT ce fichier) — absent en environnement de
|
||||
// test qui ne charge pas ce script, d'où la garde `typeof`.
|
||||
function forgeQuizTemplateEffectsFor(widget) {
|
||||
var template = (widget && widget.getAttribute('data-quiz-template')) || 'defaut';
|
||||
return (typeof forgeQuizTemplateEffects !== 'undefined' && forgeQuizTemplateEffects[template]) || {};
|
||||
}
|
||||
|
||||
// Appelée par le clic sur un choix (les boutons sont injectés EN JEU par
|
||||
@@ -346,6 +496,12 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
state.resolved = true;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
var correct = choiceIndex === state.correctIndex;
|
||||
// Minuteur "par question" : cette question est résolue (bonne réponse,
|
||||
// mauvaise réponse, OU minuteur expiré via forgeQuizBoxTimeout, qui
|
||||
// appelle CETTE fonction avec choiceIndex=-1) — jamais le minuteur
|
||||
// "pour tout le quiz", qui doit continuer à tourner sans interruption
|
||||
// d'une question à l'autre.
|
||||
if (widget && widget.getAttribute('data-timer-mode') === 'question') forgeStopQuizTimer(quizObjectId);
|
||||
if (widget) {
|
||||
var buttons = widget.querySelectorAll('.quizBoxChoiceBtn');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
@@ -355,9 +511,15 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
}
|
||||
}
|
||||
window.forgeQuizTotalAnswered += 1;
|
||||
state.answeredCount += 1;
|
||||
var fx = forgeQuizTemplateEffectsFor(widget);
|
||||
if (correct) {
|
||||
window.forgeQuizCorrectAnswered += 1;
|
||||
state.correctCount += 1;
|
||||
forgeAddQuizScore(state.rewardAmount);
|
||||
if (fx.onCorrect) fx.onCorrect(widget);
|
||||
} else if (fx.onWrong) {
|
||||
fx.onWrong(widget);
|
||||
}
|
||||
if (typeof forgeXapiNotifyQuestionAnswered === "function") {
|
||||
forgeXapiNotifyQuestionAnswered(state.dialogueId, state.objectName, state.questionIndex, state.questionText, correct, correct ? state.rewardAmount : 0);
|
||||
@@ -371,26 +533,133 @@ function forgeQuizBoxAnswer(quizObjectId, choiceIndex) {
|
||||
// après un délai fixe (700ms/1400ms), jamais assez pour un lecteur
|
||||
// lent ou un utilisateur de lecteur d'écran — l'avancée dépend
|
||||
// maintenant uniquement d'un clic sur "Continuer →", jamais d'un
|
||||
// minuteur. Le bouton est ajouté APRÈS les choix (jamais en remplaçant
|
||||
// choicesEl.innerHTML, qui effacerait le surlignage vert/rouge déjà posé).
|
||||
// minuteur. Le bouton existe déjà (posé désactivé par forgeShowQuizBox
|
||||
// ci-dessus, TOUJOURS visible — demande explicite, comme la maquette
|
||||
// manga) : on se contente ici de l'ACTIVER, jamais de le recréer (ça
|
||||
// effacerait le surlignage vert/rouge qu'on vient de poser sur les
|
||||
// choix juste au-dessus).
|
||||
if (widget) {
|
||||
var choicesEl = widget.querySelector('[data-quiz-role="choices"]');
|
||||
if (choicesEl) {
|
||||
choicesEl.innerHTML += '<button type="button" class="quizBoxContinueBtn" onclick="forgeQuizBoxContinue(' + quizObjectId + ')">Continuer →</button>';
|
||||
}
|
||||
var continueBtnToEnable = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtnToEnable) continueBtnToEnable.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Prépare l'écran de résultat de fin de quiz (voir static/js/play/
|
||||
// quiz-box-templates.js::onQuizComplete, ex. forgeQuizClassiqueOnQuizComplete)
|
||||
// et renvoie une fonction à appeler APRÈS forgeAdvanceConversation (qui
|
||||
// masque sinon le widget, voir forgeHideQuizBoxOnCurrentScreen) — null
|
||||
// si ce n'est pas la fin du quiz (forceEnd=false : encore des lignes
|
||||
// après) ou si le modèle actif n'a aucun écran de résultat (seul
|
||||
// "classique" en a un pour l'instant, "manga"/les modèles "boîte"
|
||||
// gardent leur comportement d'avant, inchangé : la boîte se masque
|
||||
// simplement). `forceEnd` (true) : le minuteur du quiz ENTIER vient
|
||||
// d'expirer (forgeQuizBoxTimeout) — le quiz se termine MÊME s'il
|
||||
// restait des questions, jamais une simple question de position dans
|
||||
// les lignes.
|
||||
function forgeQuizPrepareResultScreen(quizObjectId, widget, state, forceEnd) {
|
||||
if (!widget) return null;
|
||||
var dialogueState = forgeDialogueBoxState[state.dialogueObjectId];
|
||||
if (!dialogueState) return null;
|
||||
var isEnding = forceEnd || (dialogueState.index + 1 >= dialogueState.lines.length);
|
||||
if (!isEnding) return null;
|
||||
var completeFx = forgeQuizTemplateEffectsFor(widget).onQuizComplete;
|
||||
if (!completeFx) return null;
|
||||
var correctCount = state.correctCount;
|
||||
var answeredCount = state.answeredCount;
|
||||
return function () {
|
||||
widget.classList.add('is-active');
|
||||
if (widget.getAttribute('data-fullscreen') === '1') widget.classList.add('is-fullscreen');
|
||||
completeFx(widget, { correctCount: correctCount, totalCount: answeredCount });
|
||||
};
|
||||
}
|
||||
|
||||
// Bouton de l'écran de résultat (voir forgeQuizPrepareResultScreen
|
||||
// ci-dessus) — demande explicite : QUITTER carrément (fermer l'onglet/
|
||||
// la page du navigateur), jamais recommencer le quiz. Le dialogue est
|
||||
// déjà épuisé (forgeAdvanceConversation l'a fait avant d'afficher ce
|
||||
// résultat) — masque quand même le widget D'ABORD (repli si la fermeture
|
||||
// est refusée, voir ci-dessous), avant de tenter de fermer. "is-showing-
|
||||
// result" est retirée des DEUX emplacements possibles ("manga" la pose
|
||||
// sur la racine du widget, "classique" sur .classiqueContent, voir
|
||||
// static/js/play/quiz-box-templates.js) — l'un des deux est toujours un
|
||||
// no-op inoffensif.
|
||||
//
|
||||
// window.close() : un navigateur ne ferme RÉELLEMENT que les onglets
|
||||
// ouverts PAR SCRIPT (window.open) ou la page auto-générée d'un
|
||||
// téléchargement — jamais un onglet atteint par une navigation normale
|
||||
// (tapée, cliquée, ou un lien) : restriction de sécurité du navigateur
|
||||
// lui-même, aucun moyen de la contourner en JS. window.close() échoue
|
||||
// alors silencieusement (aucune erreur, aucun retour) : le repli
|
||||
// ci-dessus (widget déjà masqué) est donc le résultat visible le plus
|
||||
// courant en pratique.
|
||||
function forgeQuizResultQuit(quizObjectId) {
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
if (widget) {
|
||||
widget.classList.remove('is-active', 'is-fullscreen', 'is-showing-result');
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
if (typeof window !== 'undefined' && typeof window.close === 'function') window.close();
|
||||
}
|
||||
|
||||
// Appelé par le bouton "Continuer →" ajouté par forgeQuizBoxAnswer
|
||||
// ci-dessus — masque la boîte à quiz et avance la conversation, jamais
|
||||
// ci-dessus — masque la boîte à quiz et avance la conversation (SAUF à
|
||||
// la toute dernière question d'un modèle qui sait afficher un écran de
|
||||
// résultat, voir forgeQuizPrepareResultScreen ci-dessus), jamais
|
||||
// automatique (voir le commentaire ci-dessus).
|
||||
function forgeQuizBoxContinue(quizObjectId) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
if (widget) widget.classList.remove('is-active');
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, state, false);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
forgeAdvanceConversation(state.dialogueObjectId);
|
||||
if (showResultScreen) showResultScreen();
|
||||
}
|
||||
|
||||
// Minuteur expiré (demande explicite : "pouvoir paramétrer un timer pour
|
||||
// répondre aux questions", jamais imposé — "aucun" reste le réglage par
|
||||
// défaut) : mode "question" — cette question est comptée fausse (0
|
||||
// point), la bonne réponse s'affiche, puis "Continuer →" fonctionne
|
||||
// normalement. Mode "quiz" (wholeQuizExpired) — même révélation, mais
|
||||
// plus AUCUN temps pour la suite : le quiz se termine tout seul après un
|
||||
// court délai de lecture plutôt que d'attendre un clic. Réutilise
|
||||
// EXACTEMENT le chemin d'une mauvaise réponse (forgeQuizBoxAnswer) avec
|
||||
// choiceIndex=-1 (ne correspond à aucun bouton : révèle la bonne réponse
|
||||
// sans en marquer une "fausse"), jamais de code dupliqué.
|
||||
function forgeQuizBoxTimeout(quizObjectId, wholeQuizExpired) {
|
||||
var state = forgeQuizBoxState[quizObjectId];
|
||||
if (!state || state.resolved) return;
|
||||
forgeQuizBoxAnswer(quizObjectId, -1);
|
||||
if (!wholeQuizExpired) return;
|
||||
var widget = forgeQuizBoxWidgetById(quizObjectId);
|
||||
// Pas de "Continuer →" ici : rien à continuer, le temps du quiz ENTIER
|
||||
// est écoulé — désactivé/relibellé pour éviter toute course avec la
|
||||
// fin automatique programmée juste en dessous.
|
||||
if (widget) {
|
||||
var continueBtn = widget.querySelector('.quizBoxContinueBtn');
|
||||
if (continueBtn) { continueBtn.disabled = true; continueBtn.textContent = 'Temps écoulé…'; }
|
||||
}
|
||||
setTimeout(function () {
|
||||
var s = forgeQuizBoxState[quizObjectId];
|
||||
if (!s) return; // déjà fermé entre-temps
|
||||
// forceEnd=true : le quiz ENTIER se termine ici, qu'il reste ou non
|
||||
// des questions (voir forgeQuizPrepareResultScreen) — capturé AVANT
|
||||
// que forgeAdvanceConversation ne supprime forgeDialogueBoxState.
|
||||
var showResultScreen = forgeQuizPrepareResultScreen(quizObjectId, widget, s, true);
|
||||
if (widget && !showResultScreen) widget.classList.remove('is-active', 'is-fullscreen');
|
||||
delete forgeQuizBoxState[quizObjectId];
|
||||
// Force l'épuisement des lignes restantes — même sortie que la fin
|
||||
// naturelle d'un dialogue (markCompleted/thenAction inclus, voir
|
||||
// forgeAdvanceConversation), sans dupliquer cette logique.
|
||||
var dialogueState = forgeDialogueBoxState[s.dialogueObjectId];
|
||||
if (dialogueState) {
|
||||
dialogueState.index = dialogueState.lines.length;
|
||||
forgeAdvanceConversation(s.dialogueObjectId);
|
||||
}
|
||||
if (showResultScreen) showResultScreen();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que les autres
|
||||
@@ -400,9 +669,14 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
forgeShowDialogueBox, forgeDialogueBoxAdvance, forgeCompleteDialogue,
|
||||
forgeDialogueBoxWidgetOnCurrentScreen, forgeDialogueBoxWidgetById, forgeRenderDialogueBoxLine,
|
||||
forgeResetDialogueBoxFooter,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById,
|
||||
forgeUpdateAllScoreWidgets, forgeAddQuizScore, forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeShowQuizBox, forgeQuizBoxAnswer, forgeQuizBoxContinue, forgeQuizBoxTimeout,
|
||||
forgeQuizBoxWidgetOnCurrentScreen, forgeQuizBoxWidgetById, forgeHideQuizBoxOnCurrentScreen,
|
||||
forgeUpdateAllScoreWidgets, forgeUpdateQuizBoxScoreDisplays, forgeAddQuizScore,
|
||||
forgeAdvanceConversation, forgeShowCurrentConversationLine,
|
||||
forgeSyncQuizScoreToScorm, forgeSyncDialogueStartedToScorm, forgeSyncAllDialoguesCompletionToScorm,
|
||||
forgeComputeFinalScormStatus,
|
||||
forgeStartQuizTimer, forgeStopQuizTimer, forgeQuizTimerState,
|
||||
forgeEscapeCameraForFullscreen,
|
||||
forgeQuizPrepareResultScreen, forgeQuizResultQuit,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
// ---------- Effets visuels par modèle de "❓ Boîte à quiz" ----------
|
||||
// Registre appelé par static/js/play/dialogue-box-controller.js (JAMAIS
|
||||
// l'inverse) aux points d'extension du moteur PARTAGÉ (forgeShowQuizBox,
|
||||
// forgeQuizBoxAnswer) — voir screens/rendering/quiz_box_templates.py
|
||||
// pour le HTML/CSS de chaque modèle. Ajouter un futur modèle n'oblige
|
||||
// donc jamais à toucher dialogue-box-controller.js : juste une nouvelle
|
||||
// entrée ici (optionnelle) + son CSS/HTML.
|
||||
|
||||
// Effet PARTAGÉ entre plusieurs modèles "page de quiz" (Manga, Classique,
|
||||
// et tout futur modèle qui voudrait la même mécanique) — demande
|
||||
// explicite : au-dessus de la question, "Question N/M" (nombre de
|
||||
// QUESTIONS de ce dialogue, pas la position brute dans lines qui mélange
|
||||
// aussi les répliques) plutôt que le nom de l'objet déclencheur — et une
|
||||
// barre d'étapes juste en dessous du bandeau. `forgeQuizBoxState`/
|
||||
// `forgeDialogueBoxState` (déclarés dans dialogue-box-controller.js,
|
||||
// chargé à côté de ce fichier) sont des variables GLOBALES de script
|
||||
// classique (pas de module), lisibles ici telles quelles.
|
||||
// `progressSelector` : chaque modèle a son propre conteneur de barre
|
||||
// d'étapes (data-manga-role/data-classique-role, jamais data-quiz-role —
|
||||
// décoration propre à CHAQUE modèle, pas du contrat partagé).
|
||||
function forgeQuizProgressOnShow(widget, progressSelector) {
|
||||
if (!widget) return;
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
var quizState = (typeof forgeQuizBoxState !== 'undefined') ? forgeQuizBoxState[quizObjectId] : null;
|
||||
if (!quizState) return;
|
||||
var dialogueState = (typeof forgeDialogueBoxState !== 'undefined') ? forgeDialogueBoxState[quizState.dialogueObjectId] : null;
|
||||
if (!dialogueState) return;
|
||||
function isQuestion(line) { return line && line.type === 'question'; }
|
||||
var total = dialogueState.lines.filter(isQuestion).length;
|
||||
if (total === 0) return;
|
||||
var current = dialogueState.lines.slice(0, dialogueState.index + 1).filter(isQuestion).length;
|
||||
var header = widget.querySelector('[data-quiz-role="header"]');
|
||||
if (header) header.textContent = 'Question ' + current + '/' + total;
|
||||
var progressEl = widget.querySelector(progressSelector);
|
||||
if (progressEl) {
|
||||
var bars = [];
|
||||
for (var i = 1; i <= total; i++) {
|
||||
bars.push('<i class="' + (i < current ? 'is-done' : (i === current ? 'is-current' : '')) + '"></i>');
|
||||
}
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
}
|
||||
|
||||
function forgeQuizMangaOnShow(widget) {
|
||||
// Défensif : si CE widget est réutilisé pour un quiz déclenché à
|
||||
// nouveau plus tard dans la partie (voir forgeQuizResultQuit,
|
||||
// dialogue-box-controller.js — le widget lui-même n'est jamais
|
||||
// reconstruit, juste masqué), une question fraîchement affichée ne
|
||||
// doit jamais rester sur l'écran de résultat de la fois précédente.
|
||||
if (widget) widget.classList.remove('is-showing-result');
|
||||
forgeQuizProgressOnShow(widget, '[data-manga-role="progress"]');
|
||||
}
|
||||
|
||||
function forgeQuizClassiqueOnShow(widget) {
|
||||
// Même garde-fou que forgeQuizMangaOnShow ci-dessus, ici posée sur
|
||||
// .classiqueContent (pas la racine du widget, voir
|
||||
// render_quiz_box_page_classique) plutôt que sur le widget lui-même.
|
||||
if (widget) {
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
if (content) content.classList.remove('is-showing-result');
|
||||
}
|
||||
forgeQuizProgressOnShow(widget, '[data-classique-role="progress"]');
|
||||
}
|
||||
|
||||
// Écrans de résultat de fin de quiz ("classique" ci-dessous, "manga"
|
||||
// plus bas) — appelés par forgeQuizPrepareResultScreen
|
||||
// (dialogue-box-controller.js) UNIQUEMENT à la toute dernière question
|
||||
// (ou si le minuteur du quiz ENTIER expire). `result.correctCount`/
|
||||
// `result.totalCount` : comptés PAR CE QUIZ (voir forgeQuizBoxState dans
|
||||
// dialogue-box-controller.js), jamais window.forgeQuizScore/
|
||||
// forgeQuizCorrectAnswered qui cumulent TOUTE la partie. Le seuil de
|
||||
// réussite réutilise EXACTEMENT window.FORGE_SUCCESS_THRESHOLD_PERCENT
|
||||
// (déjà utilisé pour le statut SCORM final, voir
|
||||
// forgeComputeFinalScormStatus) plutôt qu'un réglage à part. Le bouton
|
||||
// QUITTE le quiz (jamais ne le recommence, demande explicite) — voir
|
||||
// forgeQuizResultQuit, dialogue-box-controller.js.
|
||||
function forgeQuizClassiqueOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
var content = widget.querySelector('.classiqueContent');
|
||||
var zone = widget.querySelector('[data-classique-role="result"]');
|
||||
if (!content || !zone) return;
|
||||
var total = (result && result.totalCount) || 0;
|
||||
var correct = (result && result.correctCount) || 0;
|
||||
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
var passed = pct >= threshold;
|
||||
var titleEl = widget.querySelector('.classiqueHeaderTitle');
|
||||
var title = titleEl ? titleEl.textContent : 'Quiz';
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML =
|
||||
'<div class="classiqueResultRing" style="--pct:' + pct + '"><span>' + pct + '%</span></div>' +
|
||||
'<p class="classiqueResultTitle">' + correct + ' bonne' + (correct > 1 ? 's' : '') + ' réponse' + (correct > 1 ? 's' : '') + ' sur ' + total + '</p>' +
|
||||
'<p class="classiqueResultSub">« ' + (typeof forgeEscapeHtml === 'function' ? forgeEscapeHtml(title) : title) + ' »</p>' +
|
||||
'<div class="classiqueResultBadge ' + (passed ? 'pass' : 'fail') + '">' +
|
||||
(passed ? '✓ Module validé' : '✕ Module non validé — seuil ' + threshold + '%') +
|
||||
'</div>' +
|
||||
'<button type="button" class="classiqueQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">✕ Quitter le quiz</button>';
|
||||
content.classList.add('is-showing-result');
|
||||
}
|
||||
|
||||
// "Manga" — même mécanique que forgeQuizClassiqueOnQuizComplete
|
||||
// ci-dessus, thème manga propre à ce modèle (voir static/style.css) :
|
||||
// pas de conteneur "content" à part ici (contrairement à
|
||||
// .classiqueContent), la classe "is-showing-result" bascule sur la
|
||||
// RACINE du widget elle-même (voir render_quiz_box_page_manga). La
|
||||
// barre d'étapes est reposée à 100% "terminé" (tous les segments
|
||||
// is-done) : forgeQuizProgressOnShow ne le fait pas toute seule, sa
|
||||
// dernière question reste "is-current" (jamais "is-done", il n'y a
|
||||
// plus de question suivante vers laquelle avancer) — un quiz FINI doit
|
||||
// visuellement se lire comme entièrement complété.
|
||||
function forgeQuizMangaOnQuizComplete(widget, result) {
|
||||
if (!widget) return;
|
||||
var zone = widget.querySelector('[data-manga-role="result"]');
|
||||
if (!zone) return;
|
||||
var total = (result && result.totalCount) || 0;
|
||||
var correct = (result && result.correctCount) || 0;
|
||||
var pct = total > 0 ? Math.round((correct / total) * 100) : 0;
|
||||
var threshold = (typeof window !== 'undefined' && typeof window.FORGE_SUCCESS_THRESHOLD_PERCENT === 'number')
|
||||
? window.FORGE_SUCCESS_THRESHOLD_PERCENT : 70;
|
||||
var passed = pct >= threshold;
|
||||
var quizObjectId = widget.getAttribute('data-object-id');
|
||||
zone.innerHTML =
|
||||
'<div class="mangaResultCard">' +
|
||||
'<span class="mangaResultTitle">Fin du quiz</span>' +
|
||||
'<p class="mangaResultScore">Score final : ' + correct + ' / ' + total + '</p>' +
|
||||
'<div class="mangaResultBadge ' + (passed ? 'pass' : 'fail') + '">' + (passed ? '合格' : '不合格') + '</div>' +
|
||||
'<div><button type="button" class="mangaQuitBtn" onclick="forgeQuizResultQuit(' + quizObjectId + ')">Quitter le quiz</button></div>' +
|
||||
'</div>';
|
||||
var progressEl = widget.querySelector('[data-manga-role="progress"]');
|
||||
if (progressEl && total > 0) {
|
||||
var bars = [];
|
||||
for (var i = 0; i < total; i++) bars.push('<i class="is-done"></i>');
|
||||
progressEl.innerHTML = bars.join('');
|
||||
}
|
||||
widget.classList.add('is-showing-result');
|
||||
}
|
||||
|
||||
var forgeQuizTemplateEffects = {
|
||||
// Modèles "boîte de dialogue" (voir screens/rendering/quiz_box_config.py
|
||||
// ::QUIZ_BOX_DIALOG_TEMPLATES) — "Verre dépoli"/"Néon"/"Minimal"/
|
||||
// "Ludique" ont été retirés (demande explicite : "supprime tous les
|
||||
// modele de boite sauf le base"). "manga_dialogue" (clé DIFFÉRENTE du
|
||||
// modèle "page" "manga" ci-dessous, même registre partagé) reste
|
||||
// proche de "defaut" — aucun effet JS particulier, juste sa propre
|
||||
// décoration CSS (voir render_quiz_box_manga_dialogue).
|
||||
defaut: {},
|
||||
manga_dialogue: {},
|
||||
// Modèles "page de quiz" (QUIZ_BOX_PAGE_TEMPLATES) — le tampon "正解"/
|
||||
// "不正解" du modèle "manga" est posé en CSS pur (::after sur
|
||||
// .is-correct/.is-wrong, voir static/style.css), seul "Question N/M" +
|
||||
// la barre d'étapes ont besoin d'un vrai calcul JS (onShow ci-dessus).
|
||||
manga: { onShow: forgeQuizMangaOnShow, onQuizComplete: forgeQuizMangaOnQuizComplete },
|
||||
classique: { onShow: forgeQuizClassiqueOnShow, onQuizComplete: forgeQuizClassiqueOnQuizComplete },
|
||||
};
|
||||
|
||||
// static/js/play/__tests__/ (node:test) — même garde que le reste de
|
||||
// static/js/play/.
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgeQuizTemplateEffects,
|
||||
forgeQuizProgressOnShow, forgeQuizMangaOnShow, forgeQuizMangaOnQuizComplete,
|
||||
forgeQuizClassiqueOnShow, forgeQuizClassiqueOnQuizComplete,
|
||||
};
|
||||
}
|
||||
@@ -796,6 +796,80 @@ function saveDialogueBoxStyle(objectId) {
|
||||
});
|
||||
}
|
||||
|
||||
// "🖼️ Modèles" (voir templates/scene_edit.html, "❓ Boîte à quiz") — DEUX
|
||||
// galeries INDÉPENDANTES (demande explicite : "boîte de dialogue" hors
|
||||
// plein écran, "page de quiz" en plein écran, jamais confondues) — une
|
||||
// SEULE des deux est rendue à la fois (voir quiz_box_config.fullscreen
|
||||
// côté serveur), chacune surligne la vignette cliquée sur sa propre
|
||||
// grille (data-selected-template) puis sauvegarde tout de suite (même
|
||||
// geste qu'un clic sur une tuile de personnage/décor).
|
||||
function _selectQuizBoxTemplate(objectId, key, gridId) {
|
||||
var grid = document.getElementById(gridId);
|
||||
if (grid) {
|
||||
grid.setAttribute('data-selected-template', key);
|
||||
var tiles = grid.querySelectorAll('.quizTemplateTile');
|
||||
for (var i = 0; i < tiles.length; i++) {
|
||||
tiles[i].classList.toggle('is-selected', tiles[i].getAttribute('data-template') === key);
|
||||
}
|
||||
}
|
||||
saveQuizBoxConfig(objectId);
|
||||
}
|
||||
function selectQuizBoxDialogTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizDialogTemplateGrid'); }
|
||||
function selectQuizBoxPageTemplate(objectId, key) { _selectQuizBoxTemplate(objectId, key, 'quizPageTemplateGrid'); }
|
||||
|
||||
// "⚙️ Options du quiz" + "🖼️ Modèles" (demande explicite : quiz RH
|
||||
// autonome — plein écran/minuteur/DEUX modèles indépendants) — mirror de
|
||||
// saveDialogueBoxStyle, mais un objet de config à PART (voir
|
||||
// screens/rendering/quiz_box_config.py), jamais mélangé à "🎨 Style"
|
||||
// (couleurs/police, inchangé, voir plus haut). Remplace l'aperçu par le
|
||||
// HTML fraîchement rendu une fois sauvegardé — un modèle change la
|
||||
// STRUCTURE du balisage (voir screens/rendering/quiz_box_templates.py),
|
||||
// pas juste une classe CSS : deviner quelle balise ajouter/retirer côté
|
||||
// client casserait la plupart des modèles.
|
||||
//
|
||||
// dialog_template/page_template : SEULE une des deux grilles est rendue
|
||||
// à la fois (voir templates/scene_edit.html) — celle qui est ABSENTE
|
||||
// garde sa valeur via #quizBoxTemplateMeta (posé par le serveur), jamais
|
||||
// écrasée par un défaut.
|
||||
//
|
||||
// "Plein écran" (demande explicite : "elle doit occuper tout l'écran",
|
||||
// "être visible par défaut", "la notion de caméra disparaît" — DANS CET
|
||||
// ÉDITEUR aussi, pas seulement au jeu) : rechargement complet dès que ce
|
||||
// réglage CHANGE de valeur — le cadre "🎥 Champ de la caméra" dépend de
|
||||
// TOUS les quiz de l'écran (pas seulement celui-ci, voir
|
||||
// routes/scenes/scene_edit_view.py::any_quiz_box_fullscreen), ET la
|
||||
// galerie de modèles à afficher change entièrement (boîte <-> page) :
|
||||
// un simple patch DOM ne pourrait recalculer ni l'un ni l'autre ici.
|
||||
function saveQuizBoxConfig(objectId) {
|
||||
var meta = document.getElementById('quizBoxTemplateMeta');
|
||||
function currentTemplate(gridId, metaAttr) {
|
||||
var grid = document.getElementById(gridId);
|
||||
if (!grid) return meta ? meta.getAttribute(metaAttr) : null;
|
||||
var selectedTile = grid.querySelector('.quizTemplateTile.is-selected');
|
||||
return grid.getAttribute('data-selected-template') || (selectedTile && selectedTile.getAttribute('data-template'));
|
||||
}
|
||||
var fullscreenEl = document.getElementById('quiz-fullscreen');
|
||||
var fullscreenChanged = fullscreenEl.checked !== (fullscreenEl.getAttribute('data-was-checked') === '1');
|
||||
var config = {
|
||||
fullscreen: fullscreenEl.checked,
|
||||
timer_mode: document.getElementById('quiz-timer-mode').value,
|
||||
timer_seconds: Number(document.getElementById('quiz-timer-seconds').value),
|
||||
dialog_template: currentTemplate('quizDialogTemplateGrid', 'data-dialog-template') || 'defaut',
|
||||
page_template: currentTemplate('quizPageTemplateGrid', 'data-page-template') || 'manga',
|
||||
};
|
||||
fetch('/game/' + GAME_SLUG + '/scene-objects/' + objectId + '/quiz-config', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: config }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (fullscreenChanged) { window.location.reload(); return; }
|
||||
var el = document.getElementById('el-' + objectId);
|
||||
var widget = el && el.querySelector('.quizBoxWidget');
|
||||
if (widget && data.rendered_html) widget.outerHTML = data.rendered_html;
|
||||
});
|
||||
}
|
||||
|
||||
function initPersonnageCommandsPanel() {
|
||||
var body = document.getElementById('personnageCommandsBody');
|
||||
if (!body) return;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 922 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
+559
-2
@@ -609,6 +609,24 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.iconGallery.personnageGallery{ grid-template-columns:repeat(2, 1fr); gap:10px; max-height:420px; }
|
||||
.personnageThumb{ width:100%; height:100%; object-fit:contain; image-rendering:pixelated; }
|
||||
|
||||
/* "🖼️ Modèles" de la "❓ Boîte à quiz" (dialogue ET page, voir
|
||||
templates/scene_edit.html, screens/rendering/quiz_box_templates.py) —
|
||||
chaque modèle est un thème complet et autonome (structure/police/
|
||||
couleurs propres) : plutôt que reproduire sa structure/CSS en
|
||||
miniature (fragile, vite désynchronisé d'un vrai thème complet), la
|
||||
vignette est une vraie capture d'écran du modèle (static/
|
||||
quiz-templates/<clé>.png, une par modèle). */
|
||||
.quizTemplateGrid{ display:grid; grid-template-columns:repeat(2, 1fr); gap:10px; margin-top:6px; }
|
||||
.quizTemplateTile{
|
||||
display:flex; flex-direction:column; gap:6px; padding:6px; background:var(--panel2);
|
||||
border:2px solid var(--border); border-radius:8px; cursor:pointer;
|
||||
}
|
||||
.quizTemplateTile:hover{ border-color:var(--accent); }
|
||||
.quizTemplateTile.is-selected{ border-color:var(--accent); box-shadow:0 0 0 2px var(--accent) inset; }
|
||||
.quizTemplateName{ font-size:11.5px; color:var(--text-dim); text-align:center; }
|
||||
.quizTemplatePreviewImg{ display:block; width:100%; height:170px; overflow:hidden; border-radius:4px; }
|
||||
.quizTemplatePreviewImg img{ width:100%; height:100%; object-fit:cover; object-position:top; display:block; }
|
||||
|
||||
/* "Mes assets" — sous-sections Images/Sons/Vidéos (demande explicite :
|
||||
"voir, utiliser ou supprimer des son, image et vidéo") : une tuile
|
||||
image garde son bouton de pose (clic/glisser-déposer, voir
|
||||
@@ -795,6 +813,27 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
}
|
||||
.playScreen .quizBoxWidget{ display:none; }
|
||||
.playScreen .quizBoxWidget.is-active{ display:flex; animation:quizPopIn .3s ease; }
|
||||
/* Un quiz PLEIN ÉCRAN est déplacé en enfant direct de <body> au premier
|
||||
affichage (voir forgeEscapeCameraForFullscreen, dialogue-box-controller.js
|
||||
— ".playScreen.playScene" est transformé/centré, ce qui casserait
|
||||
position:fixed s'il y restait) : plus dans ".playScreen" ensuite, donc
|
||||
plus couvert par les deux règles ci-dessus — cette classe reproduit le
|
||||
même masquage par défaut/affichage sur ".is-active", posée au moment du
|
||||
déplacement. */
|
||||
.quizBoxWidget--detachedForFullscreen{ display:none; }
|
||||
.quizBoxWidget--detachedForFullscreen.is-active{ display:flex; animation:quizPopIn .3s ease; }
|
||||
/* Bandeau partagé header + score/minuteur (demande explicite : "le score
|
||||
doit être à l'intérieur de la boîte à quiz, pas à l'extérieur") — le
|
||||
score (topbar) vit dans un élément FRÈRE du header (voir le
|
||||
commentaire d'en-tête de screens/rendering/quiz_box_templates.py, JS
|
||||
écrase les enfants du header via .textContent), posé ici CÔTE À CÔTE
|
||||
avec lui dans le MÊME bandeau plutôt qu'empilé au-dessus dans une bande
|
||||
à part — un seul fond commun (posé sur CE conteneur, jamais sur le
|
||||
header seul, voir _quiz_header_row_html), pas deux bandes de couleurs
|
||||
différentes. */
|
||||
.quizBoxHeaderRow{ display:flex; align-items:stretch; }
|
||||
.quizBoxHeaderRow .quizBoxHeader{ flex:1 1 auto; min-width:0; }
|
||||
.quizBoxHeaderRow .quizBoxTopBar{ width:auto; flex:0 0 auto; }
|
||||
.quizBoxHeader{ padding:8px 12px; font-weight:700; }
|
||||
.quizBoxBody{ padding:10px 12px; flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:10px; }
|
||||
.quizBoxQuestionText{ margin:0; font-weight:600; }
|
||||
@@ -818,12 +857,530 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.quizBoxChoiceBtn.is-wrong{ background:#e0524d !important; border-color:#e0524d !important; animation:quizWrongShake .4s ease; }
|
||||
/* Bouton "Continuer →" (voir forgeQuizBoxAnswer/forgeQuizBoxContinue,
|
||||
RGAA/WCAG 2.2.1) — remplace l'ancienne avancée automatique après un
|
||||
délai fixe : l'apprenant décide lui-même quand passer à la suite. */
|
||||
délai fixe : l'apprenant décide lui-même quand passer à la suite.
|
||||
TOUJOURS présent dès la question (posé désactivé par forgeShowQuizBox,
|
||||
activé par forgeQuizBoxAnswer une fois répondu — demande explicite,
|
||||
comme la maquette manga validée) : ":disabled" générique ici, commun
|
||||
à TOUS les modèles, quelle que soit leur propre couleur d'action. */
|
||||
.quizBoxContinueBtn{
|
||||
margin-top:4px; background:#2f6fed; color:#fff; border:1px solid #2f6fed;
|
||||
border-radius:6px; padding:8px 10px; font:inherit; font-size:13px; font-weight:600; cursor:pointer;
|
||||
}
|
||||
.quizBoxContinueBtn:hover{ background:#255bc4; }
|
||||
.quizBoxContinueBtn:hover:not(:disabled){ background:#255bc4; }
|
||||
.quizBoxContinueBtn:disabled{ opacity:.45; cursor:not-allowed; }
|
||||
|
||||
/* "Plein écran" (demande explicite : quiz RH autonome, voir
|
||||
screens/rendering/quiz_box_config.py) — la boîte à quiz elle-même
|
||||
devient la surface plein écran (pas besoin d'un overlay séparé comme
|
||||
static/js/play/video-overlay.js : son propre fond couvre déjà tout).
|
||||
z-index 1001 : au-dessus de .forgeVideoOverlay (1000) par précaution,
|
||||
au cas où une vidéo serait encore visible. Commun à tous les modèles. */
|
||||
.quizBoxWidget.is-fullscreen{
|
||||
position:fixed !important; inset:0 !important; left:0 !important; top:0 !important;
|
||||
width:100vw !important; height:100vh !important;
|
||||
z-index:1001 !important; display:flex !important; flex-direction:column !important;
|
||||
}
|
||||
/* Demande explicite : "pas de bordure visible, pas de bord arrondi" en
|
||||
plein écran — une bordure/un coin arrondi/une ombre portée n'ont plus
|
||||
de sens une fois que la boîte occupe TOUT l'écran (rien derrière/autour
|
||||
d'elle à en distinguer), quel que soit le modèle. */
|
||||
.quizBoxWidget.is-fullscreen{
|
||||
border-radius:0 !important; box-shadow:none !important; border:none !important;
|
||||
}
|
||||
/* Bug corrigé : "rien ne s'affiche" — align-items:center/justify-content:center
|
||||
posés ici auparavant retiraient l'étirement (stretch) par défaut d'un
|
||||
conteneur flex en colonne, réduisant .quizBoxHeader/.quizBoxBody à la
|
||||
largeur de LEUR PROPRE contenu au lieu de toute la largeur — dans
|
||||
certains navigateurs, combiné à .quizBoxBody (lui-même un conteneur
|
||||
flex en colonne, voir plus haut), ce rétrécissement en cascade
|
||||
ramenait la zone de texte à une largeur quasi nulle, rendant le
|
||||
header/la question/les choix invisibles bien qu'ils soient bien posés
|
||||
dans le DOM avec leur texte. Un simple empilement plein largeur/pleine
|
||||
hauteur (comportement déjà éprouvé hors plein écran) suffit : pas
|
||||
besoin de centrer quoi que ce soit, la boîte occupe déjà tout l'écran.*/
|
||||
.quizBoxWidget.is-fullscreen .quizBoxBody{ min-height:0; }
|
||||
/* Éditeur (voir templates/scene_edit.html) : occuper le VIEWPORT du
|
||||
navigateur (comme au jeu) casserait l'édition — un fond opaque
|
||||
couvrirait aussi le panneau de propriétés. "Plein écran" occupe ici
|
||||
tout le CANEVAS (le "monde" de la scène) à la place, posé sur
|
||||
.canvasElement (voir plus bas, jamais sur .quizBoxWidget lui-même —
|
||||
déjà forcé à 100%/100% de .canvasElement par .canvasElementInner >
|
||||
.quizBoxWidget, voir plus haut dans ce fichier) : suffisant pour que
|
||||
le créateur voie tout de suite l'effet, sans avoir à lancer le jeu.
|
||||
La "notion de caméra disparaît" (demande explicite) : le cadre "🎥
|
||||
Champ de la caméra" est masqué côté serveur dès qu'un quiz plein écran
|
||||
existe sur l'écran (voir routes/scenes/scene_edit_view.py), puisqu'il
|
||||
n'a plus aucun sens une fois que ce widget couvre tout, caméra ou pas.*/
|
||||
.canvasElement.isQuizFullscreenPreview{
|
||||
left:0 !important; top:0 !important; width:100% !important; height:100% !important; z-index:9000 !important;
|
||||
}
|
||||
/* Barre score/minuteur — visible SEULEMENT en plein écran (une boîte à
|
||||
quiz "normale", posée dans le décor, n'a pas besoin d'afficher un
|
||||
score/minuteur en permanence) : masquée par défaut, quel que soit le
|
||||
modèle, pour ne rien changer à l'apparence d'un quiz existant. */
|
||||
.quizBoxTopBar{
|
||||
display:none; width:100%; padding:6px 10px; font-weight:700; font-size:13px;
|
||||
align-items:center; justify-content:space-between; gap:10px; box-sizing:border-box;
|
||||
}
|
||||
.quizBoxWidget.is-fullscreen .quizBoxTopBar{ display:flex; }
|
||||
.quizBoxTimerDisplay:empty{ display:none; }
|
||||
.quizBoxTimerDisplay{
|
||||
font-variant-numeric:tabular-nums; padding:2px 10px; border-radius:999px;
|
||||
background:rgba(255,255,255,.15);
|
||||
}
|
||||
.quizBoxTimerDisplay.is-urgent{ background:#e0524d; color:#fff; }
|
||||
|
||||
/* ---------- Modèle "Manga" (boîte de dialogue) — voir
|
||||
screens/rendering/quiz_box_templates.py::render_quiz_box_manga_dialogue,
|
||||
validé par le créateur via une maquette interactive. Pendant, en
|
||||
petite carte flottante (JAMAIS plein écran), du modèle "page de quiz"
|
||||
"Manga" plus bas — réutilise la structure PARTAGÉE des modèles
|
||||
"boîte" (quizBoxHeaderRow/quizBoxHeader/quizBoxTopBar/quizBoxBody/
|
||||
quizBoxQuestionText/quizBoxChoices/quizBoxChoiceBtn/
|
||||
quizBoxContinueBtn), seule la décoration change. .quizMangaDialogueFrame
|
||||
porte la forme/le fond (jamais le conteneur racine, qui doit rester
|
||||
positionnable/redimensionnable normalement) — même principe que les
|
||||
anciens modèles "Verre dépoli"/"Néon". Police : Montserrat/Poppins
|
||||
(déjà auto-hébergées, voir static/vendor/fonts/fonts.css) — jamais
|
||||
Bangers/Noto Sans JP de la maquette (Google Fonts), même contrainte
|
||||
SCORM hors-ligne déjà rencontrée pour le modèle "page" "Manga". */
|
||||
.quizBoxWidget--mangaDialogue{
|
||||
position:relative; background:#151316; border-radius:0 !important;
|
||||
box-shadow:6px 6px 0 rgba(0,0,0,.4) !important; border:3px solid #000 !important;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizMangaDialogueFrame{
|
||||
display:flex; flex-direction:column; flex:1; width:100%; height:100%; overflow:hidden;
|
||||
font-family:"Poppins", system-ui, sans-serif; color:#f3ead6; background:#151316;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxHeaderRow{
|
||||
/* Identité de marque FIXE (rouge), jamais la couleur configurable du
|
||||
panneau "🎨 Style" (header_bg, posée en style INLINE par
|
||||
_quiz_header_row_html — !important nécessaire pour la dominer) —
|
||||
même principe déjà appliqué par l'ancien modèle "Ludique" avant sa
|
||||
suppression, conforme à la maquette validée. */
|
||||
background-color:#d81e3e !important;
|
||||
clip-path:polygon(0 0, 100% 0, 97% 100%, 0 100%); padding:10px 20px 12px 18px; position:relative;
|
||||
background-image:radial-gradient(rgba(255,255,255,.16) 1px, transparent 1.4px); background-size:7px 7px;
|
||||
/* Un `border` classique ne suit pas un clip-path — le liseré noir le
|
||||
long du bord dentelé (demande explicite : "des bords stylisés" sur
|
||||
la maquette) vient d'un drop-shadow qui, lui, épouse la silhouette
|
||||
RÉELLEMENT visible après découpe (contrairement à box-shadow, qui
|
||||
resterait un simple rectangle). */
|
||||
filter:drop-shadow(2px 0 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow(0 2px 0 #000);
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxHeader{
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; letter-spacing:.02em;
|
||||
text-transform:uppercase; font-size:15px; color:#fff2ec; text-shadow:2px 2px 0 #8f0f24;
|
||||
}
|
||||
/* "Quête : " devant le nom (JS n'écrit QUE le nom brut de l'objet
|
||||
déclencheur dans [data-quiz-role="header"], voir forgeShowQuizBox,
|
||||
dialogue-box-controller.js — contrat partagé par tous les modèles
|
||||
"boîte") — un ::before CSS pur, jamais touché par ce .textContent,
|
||||
pour reproduire le "Quête : X" de la maquette validée. */
|
||||
.quizBoxWidget--mangaDialogue .quizBoxHeader::before{ content:"Quête : "; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxTopBar{ margin-top:6px; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxScoreDisplay,
|
||||
.quizBoxWidget--mangaDialogue .quizBoxTimerDisplay{ background:rgba(0,0,0,.28); color:#fff; border-radius:2px; }
|
||||
/* Corps CRÈME (papier), comme la maquette — jamais la couleur
|
||||
configurable du panneau "🎨 Style" (body_bg, posée en style INLINE) :
|
||||
!important nécessaire pour la dominer, même raison que le bandeau
|
||||
ci-dessus. padding-bottom réservé pour ne jamais passer SOUS le
|
||||
bandeau de pied de page toujours visible (voir
|
||||
.quizMangaDialogueFooterBand plus bas).
|
||||
Coin dentelé du bas (demande explicite) SANS clip-path ni décor à
|
||||
part : un `background` (dégradé quasi-plat, juste 3° d'inclinaison,
|
||||
qui BASCULE net de crème à encre) — deux essais précédents (clip-path
|
||||
sur .quizBoxBody, puis un décor z-index:-1 séparé) ont chacun rogné
|
||||
ou fait disparaître le bouton "Continuer →" du moteur partagé
|
||||
(DESCENDANT de cet élément, positionné en absolute par rapport au
|
||||
widget) : un clip-path/overflow sur un ANCÊTRE le rogne TOUJOURS,
|
||||
quel que soit son propre positionnement ; un contexte d'empilement
|
||||
(position+z-index) posé ici l'emprisonne aussi, l'empêchant de
|
||||
ressortir au-dessus du bandeau bas. Un simple `background` ne clippe
|
||||
ni n'empile RIEN — le bouton reste entièrement libre. */
|
||||
.quizBoxWidget--mangaDialogue .quizBoxBody{
|
||||
/* Fond CRÈME plat (demande explicite : coins bas arrondis) — le
|
||||
dégradé diagonal précédent aurait rendu l'arrondi invisible : ses
|
||||
18% du bas étaient DÉJÀ encre, un coin arrondi encre sur un fond
|
||||
encre (le bandeau du bas) ne se voit tout simplement pas, aucun
|
||||
contraste. Coin plat + arrondi net = le seul réglage qui reste
|
||||
visible. */
|
||||
background:#f3ead6 !important;
|
||||
color:#151316; padding:16px 18px 78px; overflow:visible;
|
||||
/* Un `border-radius` seul, SANS toucher à overflow (reste visible),
|
||||
ne clippe RIEN : contrairement à un clip-path ou un
|
||||
overflow:hidden, il ne rogne le bouton "Continuer →" (descendant
|
||||
qui doit rester libre de déborder, voir le commentaire au-dessus)
|
||||
que si overflow est masqué — jamais le cas ici. */
|
||||
border-bottom-left-radius:16px; border-bottom-right-radius:16px;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoices{ overflow:visible; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxQuestionText{ font-family:"Montserrat", sans-serif; font-weight:700; color:#151316; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoices{ gap:8px; counter-reset:mangaDialogueChoice; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn{
|
||||
counter-increment:mangaDialogueChoice; position:relative; background:#fff; color:#151316;
|
||||
border:2px solid #000; border-radius:0; padding:8px 10px 8px 38px; font-family:inherit;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn::before{
|
||||
content:counter(mangaDialogueChoice, upper-alpha); position:absolute; left:8px; top:50%; transform:translateY(-50%);
|
||||
width:22px; height:22px; border-radius:50%; background:#151316; color:#f3ead6;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-size:11px;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn:hover:not(:disabled){ background:#fff7e6; }
|
||||
/* border-color:#000 !important nécessaire : le contrat partagé
|
||||
(static/style.css, règle générique .quizBoxChoiceBtn.is-correct/
|
||||
is-wrong) pose déjà un liseré vert/rouge "!important" — sans ce
|
||||
repli, il resterait visible par-dessus le fond gold/rouge-foncé
|
||||
propre à ce modèle (demande explicite : "il n'y a pas le liseré
|
||||
vert" sur la maquette — bordure NOIRE partout, jamais colorée). */
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct{ background:#e7b923 !important; border-color:#000 !important; color:#151316; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong{ background:#8f0f24 !important; border-color:#000 !important; color:#fff; text-decoration:line-through; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct::after,
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong::after{
|
||||
position:absolute; right:10px; top:50%; transform:translateY(-50%);
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-size:10px;
|
||||
padding:2px 6px; border:1.5px solid currentColor; border-radius:3px;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-correct::after{ content:"正解"; }
|
||||
.quizBoxWidget--mangaDialogue .quizBoxChoiceBtn.is-wrong::after{ content:"不正解"; }
|
||||
/* Bandeau de pied de page TOUJOURS visible (voir
|
||||
quizMangaDialogueFooterBand, render_quiz_box_manga_dialogue) — comme
|
||||
la maquette validée : une bande sombre distincte, jamais la même
|
||||
couleur que le corps crème. */
|
||||
.quizBoxWidget--mangaDialogue .quizMangaDialogueFooterBand{
|
||||
/* 44px auparavant : trop bas pour le bouton (padding 8px×2 + texte +
|
||||
bordure 2px×2 ≈ 40px) et son décalage bottom:8px — il débordait par
|
||||
le HAUT, à moitié caché sous le panneau crème (demande explicite :
|
||||
"ou dois etre le bouton"). 72px laisse une vraie marge de respir,
|
||||
comme la maquette. */
|
||||
position:absolute; left:0; right:0; bottom:0; height:72px;
|
||||
background:#151316; pointer-events:none;
|
||||
/* Coin haut-gauche dentelé (demande explicite : "les bords du bas
|
||||
doivent être retravaillés") — écho du bandeau titre découpé en
|
||||
haut, plutôt qu'un bas de carte parfaitement rectangulaire. Même
|
||||
technique drop-shadow que le bandeau titre (un `border` classique
|
||||
ne suit pas un clip-path) pour un liseré qui épouse la VRAIE
|
||||
silhouette découpée. */
|
||||
clip-path:polygon(4% 0, 100% 0, 100% 100%, 0 100%, 0 4%);
|
||||
filter:drop-shadow(0 -2px 0 #000) drop-shadow(-2px 0 0 #000) drop-shadow(2px 0 0 #000) drop-shadow(0 2px 0 #000);
|
||||
}
|
||||
/* Le bouton "Continuer →" du moteur partagé atterrit en DERNIER enfant
|
||||
de .quizBoxChoices (jamais dans quizMangaDialogueFooterBand, que le
|
||||
moteur ne remplit jamais) — posé PAR-DESSUS ce bandeau en
|
||||
position:absolute (.quizBoxWidget--mangaDialogue est position:relative,
|
||||
voir plus haut), petit et skewé aligné à droite comme la maquette
|
||||
(sans ce repli, il s'étire à la largeur des choix — .quizBoxChoices
|
||||
est un flex-column, stretch par défaut). */
|
||||
.quizBoxWidget--mangaDialogue .quizBoxContinueBtn{
|
||||
position:absolute; right:20px; bottom:20px; z-index:1; margin:0;
|
||||
background:#d81e3e; border:2px solid #000; color:#fff2ec; border-radius:0;
|
||||
padding:8px 20px; transform:skewX(-6deg); font-family:"Montserrat", sans-serif; font-weight:700;
|
||||
box-shadow:3px 3px 0 #000;
|
||||
}
|
||||
.quizBoxWidget--mangaDialogue .quizBoxContinueBtn:hover:not(:disabled){ background:#8f0f24; }
|
||||
|
||||
/* ---------- Modèle "Manga" — premier "PAGE DE QUIZ" (voir
|
||||
screens/rendering/quiz_box_templates.py::render_quiz_box_page_manga),
|
||||
validé par le créateur via une maquette interactive avant intégration.
|
||||
Thème complet et autonome : structure/police/formes/couleurs propres,
|
||||
AUCUNE règle partagée avec les modèles "boîte de dialogue" ci-dessus
|
||||
(à part .quizBoxChoiceBtn/.quizBoxContinueBtn, injectés par le moteur
|
||||
JS partagé, voir static/js/play/dialogue-box-controller.js).
|
||||
Polices : Montserrat/Poppins (déjà auto-hébergées, voir
|
||||
static/vendor/fonts/fonts.css) plutôt que Bangers/Noto Sans JP de la
|
||||
maquette d'origine — un paquet SCORM exporté tourne SANS accès
|
||||
internet dans le LMS du client, une police chargée depuis Google
|
||||
Fonts y resterait invisible (repli silencieux sur la police système). */
|
||||
.quizBoxWidget--manga{
|
||||
background:#151316; color:#f3ead6;
|
||||
font-family:"Poppins", "Montserrat", system-ui, sans-serif;
|
||||
display:flex; flex-direction:column; align-items:center; gap:18px; padding:20px;
|
||||
overflow-y:auto;
|
||||
background-image:
|
||||
radial-gradient(circle at 14% 8%, rgba(216,30,62,.16), transparent 42%),
|
||||
repeating-linear-gradient(115deg, rgba(255,255,255,.03) 0 2px, transparent 2px 26px);
|
||||
}
|
||||
/* Contenu centré en colonne confortable (demande explicite, comme la
|
||||
maquette validée) plutôt qu'étiré sur toute la largeur de l'écran. */
|
||||
.quizBoxWidget--manga .mangaTopbar,
|
||||
.quizBoxWidget--manga .mangaProgress,
|
||||
.quizBoxWidget--manga .mangaPanelQuestion,
|
||||
.quizBoxWidget--manga .mangaChoices{ width:100%; max-width:860px; flex-shrink:0; }
|
||||
/* Barre d'étapes (voir forgeQuizMangaOnShow, static/js/play/
|
||||
quiz-box-templates.js) — un <i> par question de CE dialogue, remplie
|
||||
EN JEU (le nombre total de questions n'est connu qu'à l'exécution). */
|
||||
.quizBoxWidget--manga .mangaProgress{ display:flex; gap:6px; }
|
||||
.quizBoxWidget--manga .mangaProgress i{ flex:1; height:6px; background:rgba(243,234,214,.18); border-radius:1px; font-style:normal; }
|
||||
.quizBoxWidget--manga .mangaProgress i.is-done{ background:#d81e3e; }
|
||||
.quizBoxWidget--manga .mangaProgress i.is-current{ background:#e7b923; }
|
||||
.quizBoxWidget--manga .mangaTopbar{ display:flex; align-items:stretch; gap:12px; flex-wrap:wrap; }
|
||||
.quizBoxWidget--manga .mangaPlate{
|
||||
background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:4px 4px 0 #1d1a1f;
|
||||
padding:8px 14px; display:flex; flex-direction:column; justify-content:center; gap:2px; min-width:118px;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaPlateLabel{ font-size:10px; font-weight:600; letter-spacing:.14em; text-transform:uppercase; opacity:.65; }
|
||||
.quizBoxWidget--manga .mangaPlateValue{
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-size:22px; line-height:1;
|
||||
letter-spacing:.02em; font-variant-numeric:tabular-nums;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaPlate--score .mangaPlateValue{ color:#8f0f24; }
|
||||
.quizBoxWidget--manga .mangaPlate--timer:has(.quizBoxTimerDisplay.is-urgent){
|
||||
background:#d81e3e; color:#f3ead6; animation:mangaTimerPulse .5s ease infinite;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaPlate--timer:has(.quizBoxTimerDisplay.is-urgent) .mangaPlateValue{ color:#f3ead6; }
|
||||
@keyframes mangaTimerPulse{ 0%,100%{ transform:scale(1); } 50%{ transform:scale(1.05); } }
|
||||
.quizBoxWidget--manga .mangaTitlemark{
|
||||
flex:1 1 200px; min-width:180px; background:#1d1a1f; border:3px solid #151316; outline:3px solid #f3ead6; outline-offset:-8px;
|
||||
display:flex; align-items:center; justify-content:center; padding:10px;
|
||||
background-image:radial-gradient(rgba(243,234,214,.14) 1.3px, transparent 1.5px); background-size:8px 8px;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaTitlemark span{
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; text-transform:uppercase;
|
||||
font-size:clamp(16px,3vw,24px); letter-spacing:.02em; color:#f3ead6;
|
||||
-webkit-text-stroke:1px #f3ead6; text-shadow:2px 2px 0 #d81e3e; transform:skewX(-8deg); display:inline-block;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaPanelQuestion{
|
||||
background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:6px 6px 0 #1d1a1f;
|
||||
clip-path:polygon(0 0, 100% 0%, 100% 92%, 97% 100%, 3% 100%, 0 88%);
|
||||
padding:22px 26px 30px; flex:0 0 auto;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaKicker{
|
||||
display:inline-block; font-size:11px; font-weight:700; letter-spacing:.14em; text-transform:uppercase;
|
||||
background:#d81e3e; color:#f3ead6; padding:3px 10px; margin-bottom:12px; transform:rotate(-2deg);
|
||||
}
|
||||
.quizBoxWidget--manga .mangaQuestionText{
|
||||
margin:0; font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; line-height:1.2;
|
||||
font-size:clamp(19px,2.6vw,28px); letter-spacing:-.01em; text-transform:uppercase; text-wrap:balance;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaChoices{
|
||||
display:grid; grid-template-columns:1fr 1fr; gap:14px; flex:1 1 auto; align-content:start;
|
||||
}
|
||||
@media (max-width:620px){ .quizBoxWidget--manga .mangaChoices{ grid-template-columns:1fr; } }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn{
|
||||
counter-increment:mangaChoice; position:relative; display:flex; align-items:center; gap:14px;
|
||||
background:#1d1a1f; color:#f3ead6; border:3px solid #151316; outline:2px solid rgba(243,234,214,.5); outline-offset:-7px;
|
||||
padding:12px 16px 12px 12px; font-family:inherit; font-size:15px; font-weight:500; text-align:left;
|
||||
border-radius:0; box-shadow:none;
|
||||
}
|
||||
.quizBoxWidget--manga .mangaChoices{ counter-reset:mangaChoice; }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn::before{
|
||||
content:counter(mangaChoice, upper-alpha); flex:0 0 auto; width:34px; height:34px; border-radius:50%;
|
||||
background:#f3ead6; color:#151316; border:3px solid #151316; display:flex; align-items:center; justify-content:center;
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-size:15px;
|
||||
}
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn:hover:not(:disabled){ background:#26222a; transform:translate(-2px,-2px); }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct{ background:#e7b923 !important; color:#151316; border-color:#151316 !important; }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::before{ background:#151316; color:#e7b923; border-color:#151316; }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong{ background:#8f0f24 !important; border-color:#151316 !important; }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::after,
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::after{
|
||||
position:absolute; top:50%; right:14px; transform:translateY(-50%) rotate(-8deg);
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-size:13px; letter-spacing:.06em;
|
||||
padding:2px 8px; border:2px solid currentColor; border-radius:4px;
|
||||
}
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-correct::after{ content:"正解"; color:#151316; }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::after{ content:"不正解"; color:#f3ead6; }
|
||||
/* Une réponse fausse est barrée (demande explicite, comme la maquette) —
|
||||
jamais le badge lettré (::before), qui reste un simple repère A/B/C/D. */
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong{ text-decoration:line-through; text-decoration-thickness:2px; }
|
||||
.quizBoxWidget--manga .quizBoxChoiceBtn.is-wrong::before{ text-decoration:none; }
|
||||
/* Posé DANS la grille des choix (voir forgeQuizBoxAnswer, moteur partagé)
|
||||
mais sur sa propre ligne, aligné à droite en petit pavé (comme la
|
||||
maquette) plutôt qu'étiré à la largeur d'une case de la grille — et
|
||||
relabellisé en "Suite ▶" (texte "Continuer →" du moteur partagé
|
||||
conservé pour les lecteurs d'écran, juste masqué visuellement). */
|
||||
.quizBoxWidget--manga .quizBoxContinueBtn{
|
||||
grid-column:1 / -1; justify-self:end; width:max-content;
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; letter-spacing:.03em; font-size:0;
|
||||
background:#d81e3e; color:#f3ead6; border:3px solid #151316; box-shadow:3px 3px 0 #1d1a1f;
|
||||
padding:9px 22px; transform:skewX(-6deg); border-radius:0;
|
||||
}
|
||||
.quizBoxWidget--manga .quizBoxContinueBtn::after{ content:"Suite ▶"; font-size:14px; }
|
||||
.quizBoxWidget--manga .quizBoxContinueBtn:hover:not(:disabled){ background:#8f0f24; }
|
||||
|
||||
/* Écran de résultat de fin de quiz (voir forgeQuizMangaOnQuizComplete,
|
||||
static/js/play/quiz-box-templates.js) — demande explicite : "il faut
|
||||
aussi adapter le modele manga pour avoir sa page de résultat", même
|
||||
mécanique que "classique" mais dans le thème manga propre à ce
|
||||
modèle. Pas de conteneur "content" à part ici : c'est la RACINE
|
||||
(.quizBoxWidget--manga) qui bascule en "is-showing-result" — le
|
||||
bandeau topbar/score et la barre d'étapes (déjà remplie à 100%,
|
||||
voir le JS) restent visibles, seuls le panneau question et les choix
|
||||
disparaissent. */
|
||||
.quizBoxWidget--manga.is-showing-result > .mangaPanelQuestion,
|
||||
.quizBoxWidget--manga.is-showing-result > .mangaChoices{ display:none; }
|
||||
.mangaResultZone{
|
||||
display:none; flex-direction:column; align-items:center; justify-content:center;
|
||||
width:100%; max-width:860px; text-align:center; flex:1 1 auto;
|
||||
}
|
||||
.quizBoxWidget--manga.is-showing-result .mangaResultZone{ display:flex; }
|
||||
.mangaResultCard{
|
||||
background:#f3ead6; color:#151316; border:3px solid #151316; box-shadow:6px 6px 0 #1d1a1f;
|
||||
clip-path:polygon(0 0, 100% 0%, 100% 92%, 97% 100%, 3% 100%, 0 88%);
|
||||
padding:32px 30px 40px; width:100%;
|
||||
}
|
||||
.mangaResultTitle{
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; font-style:italic; text-transform:uppercase;
|
||||
font-size:clamp(26px,4vw,38px); text-shadow:3px 3px 0 #d81e3e; transform:skewX(-4deg);
|
||||
display:inline-block; margin:0 0 10px;
|
||||
}
|
||||
.mangaResultScore{ font-family:"Montserrat", sans-serif; font-weight:700; font-size:19px; color:#8f0f24; margin:0 0 18px; }
|
||||
.mangaResultBadge{
|
||||
display:inline-block; font-family:"Montserrat", sans-serif; font-weight:700; font-size:22px;
|
||||
letter-spacing:.05em; padding:6px 22px; border:3px solid #151316; margin-bottom:24px; transform:rotate(-2deg);
|
||||
}
|
||||
.mangaResultBadge.pass{ background:#e7b923; }
|
||||
.mangaResultBadge.fail{ background:#8f0f24; color:#f3ead6; }
|
||||
.mangaQuitBtn{
|
||||
font-family:"Montserrat", sans-serif; font-weight:700; letter-spacing:.03em; cursor:pointer;
|
||||
background:#151316; color:#f3ead6; border:3px solid #151316; box-shadow:3px 3px 0 #d81e3e;
|
||||
padding:10px 26px; transform:skewX(-6deg); border-radius:0;
|
||||
}
|
||||
.mangaQuitBtn:hover{ background:#26222a; }
|
||||
|
||||
/* "Classique" (modèle "page de quiz" DE BASE) — maquette interactive
|
||||
validée par le créateur (formation sécurité incendie comme exemple) :
|
||||
couleurs VOLONTAIREMENT identiques à celles par défaut du modèle
|
||||
"boîte de dialogue" de base (#2f6fed/#1a1d24/#11141b, voir
|
||||
screens/rendering/dialogue_box_style.py::DEFAULT_DIALOGUE_BOX_STYLE)
|
||||
plutôt qu'une palette propre — contrairement à "manga". */
|
||||
/* Police : "Poppins" (déjà auto-hébergée pour "manga", voir
|
||||
static/vendor/fonts/fonts.css) — jamais "IBM Plex Sans/Mono" de la
|
||||
maquette (Google Fonts), qui casserait l'export SCORM hors-ligne, même
|
||||
contrainte déjà rencontrée pour "manga". */
|
||||
.quizBoxWidget--classique{
|
||||
position:relative; display:flex; flex-direction:column; font-family:"Poppins", system-ui, sans-serif;
|
||||
}
|
||||
.classiqueHeaderBar{
|
||||
display:flex; align-items:center; justify-content:space-between; gap:14px; flex-wrap:wrap;
|
||||
padding:14px 20px; color:#fff;
|
||||
}
|
||||
.classiqueHeaderTitleWrap{ display:flex; flex-direction:column; gap:1px; min-width:0; }
|
||||
.classiqueHeaderEyebrow{ font-size:10px; font-weight:600; letter-spacing:.09em; text-transform:uppercase; color:rgba(255,255,255,.7); }
|
||||
.classiqueHeaderTitle{ font-size:16px; font-weight:700; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.classiqueHeaderChips{ display:flex; gap:8px; flex-shrink:0; }
|
||||
.classiqueChip{
|
||||
display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
background:rgba(255,255,255,.14); border-radius:8px; padding:4px 12px; min-width:56px;
|
||||
}
|
||||
.classiqueChipLabel{ font-size:9px; font-weight:600; letter-spacing:.08em; text-transform:uppercase; color:rgba(255,255,255,.65); }
|
||||
.classiqueChipValue{
|
||||
font-family:"Roboto", monospace; font-size:13.5px; font-weight:600; color:#fff;
|
||||
font-variant-numeric:tabular-nums;
|
||||
}
|
||||
/* Bug identifié (demande répétée : "le mot aucun a une background
|
||||
color... un bord arrondi") : [data-quiz-role="timer"] porte AUSSI la
|
||||
classe partagée .quizBoxTimerDisplay (voir plus haut dans ce fichier —
|
||||
nécessaire pour le mécanisme commun ":empty"/".is-urgent") qui pose
|
||||
SA PROPRE pastille (fond + border-radius:999px) — DEUX pastilles
|
||||
s'empilaient donc : celle, correcte et voulue, du conteneur
|
||||
".classiqueChip" (même fond que "Score"), et celle, parasite, de
|
||||
".quizBoxTimerDisplay" collée UNIQUEMENT autour du mot "Aucun"/du
|
||||
temps restant. On neutralise ICI cette seconde pastille (le
|
||||
conteneur ".classiqueChip" reste seul responsable du fond, TOUJOURS
|
||||
visible, identique que pour "Score", avec ou sans minuteur réglé). */
|
||||
.classiqueChipValue.quizBoxTimerDisplay{ background:none; padding:0; border-radius:0; }
|
||||
.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent){ background:rgba(224,82,77,.35); }
|
||||
.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent) .classiqueChipValue,
|
||||
.classiqueChip--timer:has(.quizBoxTimerDisplay.is-urgent) .classiqueChipLabel{ color:#ffb3b0; }
|
||||
|
||||
.classiqueBody{ flex:1; display:flex; flex-direction:column; align-items:center; padding:24px 20px 96px; overflow-y:auto; }
|
||||
.classiqueContent{ width:100%; max-width:640px; display:flex; flex-direction:column; flex:1; }
|
||||
|
||||
/* Barre d'étapes — voir forgeQuizClassiqueOnShow (static/js/play/
|
||||
quiz-box-templates.js), même mécanique que "manga". */
|
||||
.classiqueProgress{ display:flex; gap:6px; margin-bottom:24px; }
|
||||
.classiqueProgress i{ flex:1; height:4px; border-radius:2px; background:rgba(255,255,255,.14); font-style:normal; }
|
||||
.classiqueProgress i.is-done{ background:#2ecc71; }
|
||||
.classiqueProgress i.is-current{ background:#fff; }
|
||||
|
||||
.classiqueKicker{
|
||||
display:inline-block; align-self:flex-start; background:rgba(255,255,255,.14); border-radius:999px;
|
||||
padding:4px 12px; font-size:11.5px; font-weight:600; color:rgba(255,255,255,.75); margin-bottom:16px;
|
||||
}
|
||||
.classiqueQuestionText{ margin:0 0 22px; font-weight:600; line-height:1.35; font-size:clamp(18px,2.6vw,22px); text-wrap:balance; }
|
||||
|
||||
.classiqueChoices{ display:flex; flex-direction:column; gap:10px; }
|
||||
.classiqueChoices .quizBoxChoiceBtn{
|
||||
display:flex; align-items:center; gap:14px; border-radius:8px; padding:13px 16px;
|
||||
}
|
||||
.classiqueChoices .quizBoxChoiceBtn.is-correct{ color:#08210f; }
|
||||
.classiqueChoices .quizBoxChoiceBtn.is-wrong{ text-decoration:line-through; opacity:.85; }
|
||||
/* Bandeau de pied de page TOUJOURS visible (voir classiqueFooterBand,
|
||||
render_quiz_box_page_classique) — couleur configurable "🎨 Style"
|
||||
(footer_bg, voir --classique-footer-bg posée sur la racine du widget)
|
||||
plutôt qu'une couleur figée en dur, demande explicite : "il manque le
|
||||
footer foncé avec le bouton" (comme la maquette validée). */
|
||||
.classiqueFooterBand{
|
||||
position:absolute; left:0; right:0; bottom:0; height:66px;
|
||||
background:var(--classique-footer-bg, #11141b); border-top:1px solid rgba(255,255,255,.08);
|
||||
pointer-events:none;
|
||||
}
|
||||
/* Le bouton "Continuer →" du moteur partagé atterrit en DERNIER enfant
|
||||
de .classiqueChoices (jamais dans classiqueFooterBand, que le moteur
|
||||
ne remplit jamais) — posé PAR-DESSUS ce bandeau en position:absolute
|
||||
(.quizBoxWidget--classique est position:relative, voir plus haut).
|
||||
Demande explicite : aligné à DROITE — dans la même colonne centrée de
|
||||
640px que le corps (.classiqueContent) : `max(20px, calc(50vw - 320px))`
|
||||
recalcule le bord DROIT de CETTE colonne (640px = 2×320px, 20px = le
|
||||
padding latéral de .classiqueBody) plutôt que le bord droit de TOUT le
|
||||
widget. 50vw est fiable ici : ce modèle n'existe qu'en plein écran, où
|
||||
`.is-fullscreen` force déjà `width:100vw`. */
|
||||
.classiqueChoices .quizBoxContinueBtn{
|
||||
position:absolute; right:max(20px, calc(50vw - 320px)); bottom:14px; z-index:1; margin:0;
|
||||
background:#2f6fed; border:1px solid #2f6fed; border-radius:7px; padding:10px 22px; font-weight:600;
|
||||
}
|
||||
.classiqueChoices .quizBoxContinueBtn:hover:not(:disabled){ background:#255bc4; }
|
||||
|
||||
/* Encart d'explication après réponse — RÉSERVÉ pour une fonctionnalité
|
||||
FUTURE non encore implémentée (voir le commentaire dans
|
||||
render_quiz_box_page_classique, screens/rendering/quiz_box_templates.py)
|
||||
: reste toujours vide aujourd'hui, donc toujours caché — seul :not(:empty)
|
||||
le fera apparaître le jour où un vrai contenu y sera écrit. */
|
||||
.classiqueExplain:not(:empty){
|
||||
margin-top:14px; padding:14px 16px; border-radius:8px; background:rgba(255,255,255,.06);
|
||||
border:1px solid rgba(255,255,255,.14); font-size:13.5px; color:rgba(255,255,255,.75); line-height:1.5;
|
||||
}
|
||||
|
||||
/* Écran de résultat de fin de quiz (voir forgeQuizClassiqueOnQuizComplete/
|
||||
forgeQuizResultRestart, static/js/play/quiz-box-templates.js et
|
||||
dialogue-box-controller.js) — demande explicite : "la page de resultat
|
||||
est cassee... elle affiche un conteneur qui reaffiche le contenu du
|
||||
quiz au lieu du resultat" (le vrai moteur n'avait jusqu'ici AUCUN écran
|
||||
de fin, juste la maquette artifact). ".classiqueContent.is-showing-
|
||||
result" masque tout le reste (progression/question/choix/explication)
|
||||
pour ne laisser QUE ce bloc visible, sans reconstruire le DOM du
|
||||
widget (le contrat data-quiz-role="header/question/choices/score/
|
||||
timer" reste intact pour un "Recommencer" qui relance normalement). */
|
||||
.classiqueContent.is-showing-result > *{ display:none; }
|
||||
.classiqueContent.is-showing-result .classiqueResultZone{ display:flex; }
|
||||
.classiqueResultZone{
|
||||
display:none; flex:1; flex-direction:column; align-items:center; justify-content:center;
|
||||
text-align:center; padding:20px 0;
|
||||
}
|
||||
.classiqueResultRing{
|
||||
width:112px; height:112px; margin:0 0 20px; border-radius:50%;
|
||||
display:flex; align-items:center; justify-content:center; position:relative;
|
||||
background:conic-gradient(#2f6fed calc(var(--pct, 0) * 1%), rgba(255,255,255,.14) 0);
|
||||
}
|
||||
.classiqueResultRing::before{
|
||||
content:""; position:absolute; inset:8px; border-radius:50%; background:var(--classique-body-bg, #1a1d24);
|
||||
}
|
||||
.classiqueResultRing span{ position:relative; font-weight:700; font-size:22px; font-variant-numeric:tabular-nums; }
|
||||
.classiqueResultTitle{ font-size:19px; font-weight:700; margin:0 0 6px; }
|
||||
.classiqueResultSub{ font-size:13.5px; color:rgba(255,255,255,.62); margin:0 0 22px; }
|
||||
.classiqueResultBadge{
|
||||
display:inline-flex; align-items:center; gap:6px; padding:6px 16px; border-radius:999px;
|
||||
font-size:12.5px; font-weight:700; margin-bottom:26px;
|
||||
}
|
||||
.classiqueResultBadge.pass{ background:rgba(46,204,113,.16); color:#2ecc71; }
|
||||
.classiqueResultBadge.fail{ background:rgba(224,82,77,.16); color:#e0524d; }
|
||||
.classiqueQuitBtn{
|
||||
font:inherit; font-weight:600; font-size:13.5px; color:#fff; background:transparent;
|
||||
border:1px solid rgba(255,255,255,.3); border-radius:9px; padding:10px 20px; cursor:pointer;
|
||||
}
|
||||
.classiqueQuitBtn:hover{ border-color:#2f6fed; background:rgba(255,255,255,.08); }
|
||||
|
||||
/* Widget "🏆 Score" — TOUJOURS visible une fois posé (pas de collision/
|
||||
déclencheur, contrairement aux deux widgets ci-dessus), voir
|
||||
|
||||
+14
-1
@@ -42,6 +42,18 @@
|
||||
#playFrame.sceneMode{ width:100vw; height:100vh; }
|
||||
.playScreen{ position:absolute; inset:0; display:none; }
|
||||
.playScreen.playScene{ inset:auto; top:50%; left:50%; transform:translate(-50%, -50%); background:#1a1d24; }
|
||||
/* "La notion de caméra disparaît" quand un quiz plein écran existe sur
|
||||
cet écran (demande explicite, voir screens/payload/full_game_payload.py
|
||||
::any_quiz_box_fullscreen) — le réglage "Caméra (px)" (scene_width/
|
||||
scene_height, qui donne à .playScene ci-dessus sa taille ET son
|
||||
`transform` de centrage) ne s'applique alors plus DU TOUT : cet écran
|
||||
devient lui-même plein viewport, sans transform (qui casserait
|
||||
position:fixed pour tout descendant, voir dialogue-box-controller.js
|
||||
::forgeEscapeCameraForFullscreen — plus nécessaire ici puisque
|
||||
.playScene n'a elle-même plus de transform dans ce cas). */
|
||||
.playScreen.playScene.playScene--quizFullscreen{
|
||||
position:fixed; inset:0; top:0; left:0; width:100vw; height:100vh; transform:none;
|
||||
}
|
||||
.playElement{
|
||||
position:absolute; box-sizing:border-box; font-family:system-ui,sans-serif;
|
||||
}
|
||||
@@ -89,7 +101,7 @@
|
||||
<!-- Un seul moteur d'écran désormais (jeu_2d) : objets déjà entièrement
|
||||
positionnés en pixels absolus par render_scene_object.py (voir
|
||||
screens/scenes/) — juste le HTML tel quel. -->
|
||||
<div class="playScreen playScene" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="width:{{ s.scene_width }}px; height:{{ s.scene_height }}px;">
|
||||
<div class="playScreen playScene {{ 'playScene--quizFullscreen' if s.any_quiz_box_fullscreen else '' }}" data-screen-id="{{ s.id }}" data-kind="jeu_2d" style="{{ '' if s.any_quiz_box_fullscreen else 'width:' + s.scene_width|string + 'px; height:' + s.scene_height|string + 'px;' }}">
|
||||
<!-- .sceneWorld : le "monde" (voir static/style.css et
|
||||
static/js/play/personnage-controller.js) — taille par défaut 100%
|
||||
(identique au viewport ci-dessus), agrandie/décalée en JS
|
||||
@@ -180,6 +192,7 @@
|
||||
<script src="{{ asset_url('js/play/actions.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/triggers.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/personnage-controller.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/quiz-box-templates.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/dialogue-box-controller.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/video-overlay.js') }}"></script>
|
||||
<script src="{{ asset_url('js/play/collision-rules-controller.js') }}"></script>
|
||||
|
||||
+110
-4
@@ -274,9 +274,20 @@
|
||||
|
||||
<div class="builderTabPanel active" data-tab-panel="screen">
|
||||
<div class="canvasFrame">
|
||||
<div class="canvas" id="canvas" style="width:{{ world_width }}px; height:{{ world_height }}px; aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;"
|
||||
<!-- Un quiz plein écran fait disparaître la notion de caméra
|
||||
(demande explicite) — pas seulement le cadre "🎥 Champ de la
|
||||
caméra" (déjà masqué plus bas), mais le CANEVAS lui-même :
|
||||
sans ce cas particulier, il restait dimensionné en pixels
|
||||
fixes (world_width/world_height, une notion de caméra/scène
|
||||
nominale) — .canvasElement.isQuizFullscreenPreview a beau
|
||||
remplir 100% de CE canevas, le canevas restait lui-même de la
|
||||
taille de la caméra, laissant une bande vide visible autour.
|
||||
width/height:100% (voir la règle .canvas déjà prévue pour ça
|
||||
dans static/style.css) fait remplir tout .canvasFrame à la
|
||||
place, indépendamment de toute taille de scène/caméra. -->
|
||||
<div class="canvas" id="canvas" style="{{ 'width:100%; height:100%;' if any_quiz_box_fullscreen else 'width:' + world_width|string + 'px; height:' + world_height|string + 'px;' }} aspect-ratio:auto; margin:0 auto; position:relative; overflow:hidden; background:#1a1d24;"
|
||||
ondragover="onCanvasDragOver(event)" ondrop="onCanvasDrop(event)">
|
||||
{% if world_width > screen.scene_width or world_height > screen.scene_height %}
|
||||
{% if not any_quiz_box_fullscreen and (world_width > screen.scene_width or world_height > screen.scene_height) %}
|
||||
<!-- "🎥 Champ de la caméra" : le monde (ce canevas, voir
|
||||
world_width/world_height ci-dessus, routes/scenes/
|
||||
scene_edit_view.py) peut être plus grand que la scène
|
||||
@@ -286,11 +297,14 @@
|
||||
controller.js::forgeUpdateSceneCamera), pour que le monde
|
||||
agrandi reste lisible plutôt qu'une simple étendue grise
|
||||
sans repère. Purement visuel (pointer-events:none) : ne
|
||||
gêne jamais la sélection/le glisser d'un objet dessous. -->
|
||||
gêne jamais la sélection/le glisser d'un objet dessous.
|
||||
Masqué dès qu'un quiz PLEIN ÉCRAN existe sur cet écran
|
||||
(demande explicite : "la notion de caméra disparaît") —
|
||||
il n'a plus aucun sens une fois que ce widget couvre tout. -->
|
||||
<div class="sceneCameraFrame" style="width:{{ screen.scene_width }}px; height:{{ screen.scene_height }}px;" title="Champ de la caméra en jeu ({{ screen.scene_width }}×{{ screen.scene_height }} px)"></div>
|
||||
{% endif %}
|
||||
{% for o in objects %}
|
||||
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }}" id="el-{{ o.id }}"
|
||||
<div class="canvasElement {{ 'selected' if o.id == selected_id else '' }} {{ 'isQuizFullscreenPreview' if o.quiz_fullscreen else '' }}" id="el-{{ o.id }}"
|
||||
style="position:absolute; left:{{ o.x }}px; top:{{ o.y }}px; width:{{ o.width }}px; height:{{ o.height }}px;"
|
||||
onmousedown="onSceneObjectMouseDown(event, {{ o.id }})">
|
||||
<div class="canvasElementInner">{{ o.rendered_html|safe }}</div>
|
||||
@@ -562,6 +576,98 @@
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if obj.kind == 'quiz_box' %}
|
||||
<!-- "⚙️ Options du quiz" (demande explicite : "étendre les
|
||||
fonctionnalités de la boîte à quiz pour les RH qui veulent
|
||||
juste faire un quiz") — voir screens/rendering/quiz_box_config.py,
|
||||
routes/scenes/scene_object_quiz_config.py. Plein écran/minuteur,
|
||||
DISTINCT de "🎨 Style" ci-dessus (couleurs/police, partagé avec
|
||||
dialogue_box/score_widget). -->
|
||||
<details class="propGroup" open>
|
||||
<summary>Options du quiz</summary>
|
||||
<div class="propGroupBody">
|
||||
<label class="checkbox is-small">
|
||||
<input type="checkbox" id="quiz-fullscreen" data-was-checked="{{ '1' if quiz_box_config.fullscreen else '0' }}" {{ 'checked' if quiz_box_config.fullscreen else '' }} onchange="saveQuizBoxConfig({{ obj.id }})">
|
||||
Plein écran
|
||||
</label>
|
||||
<div class="field mt-2">
|
||||
<label class="label is-small">Minuteur</label>
|
||||
<div class="control">
|
||||
<select class="input is-small" id="quiz-timer-mode" onchange="saveQuizBoxConfig({{ obj.id }})">
|
||||
{% for mode in quiz_box_timer_modes %}
|
||||
<option value="{{ mode }}" {{ 'selected' if quiz_box_config.timer_mode == mode else '' }}>
|
||||
{{ {'aucun': 'Aucun', 'question': 'Par question', 'quiz': 'Pour tout le quiz'}[mode] }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="label is-small">Durée (secondes)</label>
|
||||
<div class="control">
|
||||
<input class="input is-small" type="number" id="quiz-timer-seconds" min="3" max="600" value="{{ quiz_box_config.timer_seconds }}" onchange="saveQuizBoxConfig({{ obj.id }})">
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">Le plein écran et le minuteur ne s'appliquent qu'au jeu (jamais dans cet éditeur).</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- "🖼️ Modèles" — DEUX catégories bien distinctes (demande
|
||||
explicite), une SEULE des deux visible/modifiable à la fois
|
||||
selon "Plein écran" ci-dessus (les deux valeurs restent
|
||||
conservées indépendamment, voir screens/rendering/
|
||||
quiz_box_config.py) :
|
||||
- "boîte de dialogue" (hors plein écran) : variantes de forme/
|
||||
couleur sur une structure proche de "Classique" ;
|
||||
- "page de quiz" (plein écran) : thème complet et autonome,
|
||||
structure HTML propre à CHAQUE modèle (voir
|
||||
screens/rendering/quiz_box_templates.py,
|
||||
render_quiz_box_page_manga pour le premier).
|
||||
Ce <div> caché retient la valeur de CELUI des deux réglages
|
||||
qui n'a pas de galerie affichée ici, pour que saveQuizBoxConfig
|
||||
(scene-editor.js) ne l'écrase jamais par erreur en l'absence de
|
||||
sa grille. -->
|
||||
<div id="quizBoxTemplateMeta" data-dialog-template="{{ quiz_box_config.dialog_template }}" data-page-template="{{ quiz_box_config.page_template }}" style="display:none;"></div>
|
||||
|
||||
{% if quiz_box_config.fullscreen %}
|
||||
<details class="propGroup" open>
|
||||
<summary>Modèles — page de quiz</summary>
|
||||
<div class="propGroupBody">
|
||||
<p class="help mb-2">Visibles seulement en plein écran — un thème complet et autonome par modèle (police, formes, couleurs et structure propres à chacun).</p>
|
||||
<div class="quizTemplateGrid" id="quizPageTemplateGrid" data-object-id="{{ obj.id }}">
|
||||
{% for key in quiz_box_page_templates %}
|
||||
<button type="button" class="quizTemplateTile {{ 'is-selected' if quiz_box_config.page_template == key else '' }}"
|
||||
data-template="{{ key }}" onclick="selectQuizBoxPageTemplate({{ obj.id }}, '{{ key }}')">
|
||||
<span class="quizTemplatePreviewImg">
|
||||
<img src="{{ url_for('static', filename='quiz-templates/' + key + '.png') }}" alt="Aperçu du modèle « {{ quiz_box_page_template_labels[key] }} »" loading="lazy">
|
||||
</span>
|
||||
<span class="quizTemplateName">{{ quiz_box_page_template_labels[key] }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<details class="propGroup" open>
|
||||
<summary>Modèles — boîte de dialogue</summary>
|
||||
<div class="propGroupBody">
|
||||
<p class="help mb-2">Visibles seulement hors plein écran — un thème complet et autonome par modèle (police, formes, couleurs et structure propres à chacun).</p>
|
||||
<div class="quizTemplateGrid" id="quizDialogTemplateGrid" data-object-id="{{ obj.id }}">
|
||||
{% for key in quiz_box_dialog_templates %}
|
||||
<button type="button" class="quizTemplateTile {{ 'is-selected' if quiz_box_config.dialog_template == key else '' }}"
|
||||
data-template="{{ key }}" onclick="selectQuizBoxDialogTemplate({{ obj.id }}, '{{ key }}')">
|
||||
<span class="quizTemplatePreviewImg">
|
||||
<img src="{{ url_for('static', filename='quiz-templates/' + key + '.png') }}" alt="Aperçu du modèle « {{ quiz_box_dialog_template_labels[key] }} »" loading="lazy">
|
||||
</span>
|
||||
<span class="quizTemplateName">{{ quiz_box_dialog_template_labels[key] }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if obj.kind not in ('fond', 'dialogue_box', 'quiz_box', 'score_widget') %}
|
||||
<!-- Boîte de collision (voir screens/rendering/collision_settings.py,
|
||||
static/js/play/conditions.js::elementsOverlap) : posée AUTOMATIQUEMENT
|
||||
|
||||
@@ -182,6 +182,29 @@ def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action():
|
||||
assert "APPUYER SUR UNE TOUCHE" in ai.chat._SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_documents_the_quiz_box_config_tool():
|
||||
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
|
||||
(plein écran/minuteur/modèle d'un quiz autonome) — pas seulement
|
||||
l'éditeur manuel."""
|
||||
prompt = ai.chat._SYSTEM_PROMPT
|
||||
assert "set_quiz_box_config" in prompt
|
||||
for keyword in ("fullscreen", "timer_mode", "dialog_template", "page_template"):
|
||||
assert keyword in prompt, keyword
|
||||
|
||||
|
||||
def test_system_prompt_asks_ruby_to_clarify_the_timer_before_enabling_it():
|
||||
assert "jamais imposé" in ai.chat._SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_documents_the_two_quiz_template_categories():
|
||||
"""Demande explicite : "Ruby doit également comprendre cette
|
||||
distinction" (modèles "boîte de dialogue" hors plein écran vs modèles
|
||||
"page de quiz" en plein écran, structures HTML totalement séparées)."""
|
||||
prompt = ai.chat._SYSTEM_PROMPT
|
||||
assert "page de quiz" in prompt
|
||||
assert "manga" in prompt
|
||||
|
||||
|
||||
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
|
||||
@@ -168,6 +168,34 @@ def test_dispatch_set_object_collision_updates_the_settings(client, tmp_game_slu
|
||||
assert settings["shape"] == "cercle"
|
||||
|
||||
|
||||
def test_dispatch_set_quiz_box_config_updates_the_settings(client, tmp_game_slug_cleanup):
|
||||
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
|
||||
(plein écran/minuteur/modèle d'une "❓ Boîte à quiz", voir
|
||||
screens/rendering/quiz_box_config.py) — pas seulement l'éditeur
|
||||
manuel."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
dispatch_tool(slug, screen_id, 1, "set_quiz_box_config", {
|
||||
"object_id": object_id, "fullscreen": True, "timer_mode": "question",
|
||||
"timer_seconds": 15, "dialog_template": "defaut", "page_template": "manga",
|
||||
})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
config = screens.resolve_quiz_box_config(obj)
|
||||
assert config == {
|
||||
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
|
||||
"dialog_template": "defaut", "page_template": "manga",
|
||||
}
|
||||
|
||||
|
||||
def test_dispatch_set_quiz_box_config_defaults_to_no_fullscreen_no_timer(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
dispatch_tool(slug, screen_id, 1, "set_quiz_box_config", {"object_id": object_id})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
config = screens.resolve_quiz_box_config(obj)
|
||||
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
|
||||
|
||||
|
||||
def test_dispatch_create_global_variable_calls_db_directly(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
result = dispatch_tool(slug, screen_id, 1, "create_global_variable", {"name": "score_ia", "var_type": "nombre_entier"})
|
||||
@@ -417,3 +445,18 @@ def test_set_object_role_enum_matches_the_source_constant():
|
||||
def test_set_object_collision_shape_enum_matches_the_source_constant():
|
||||
schema = _tool("set_object_collision")["input_schema"]["properties"]["shape"]
|
||||
assert schema["enum"] == list(screens.COLLISION_SHAPES)
|
||||
|
||||
|
||||
def test_set_quiz_box_config_timer_mode_enum_matches_the_source_constant():
|
||||
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["timer_mode"]
|
||||
assert schema["enum"] == list(screens.TIMER_MODES)
|
||||
|
||||
|
||||
def test_set_quiz_box_config_dialog_template_enum_matches_the_source_constant():
|
||||
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["dialog_template"]
|
||||
assert schema["enum"] == list(screens.QUIZ_BOX_DIALOG_TEMPLATES)
|
||||
|
||||
|
||||
def test_set_quiz_box_config_page_template_enum_matches_the_source_constant():
|
||||
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["page_template"]
|
||||
assert schema["enum"] == list(screens.QUIZ_BOX_PAGE_TEMPLATES)
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
"""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 screens/rendering/
|
||||
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."""
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_quiz_config"):
|
||||
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
|
||||
|
||||
|
||||
# ---------- 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
|
||||
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)
|
||||
config = screens.resolve_quiz_box_config(obj)
|
||||
assert config == screens.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):
|
||||
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})
|
||||
assert config["timer_seconds"] == 600
|
||||
config = 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):
|
||||
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",
|
||||
})
|
||||
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):
|
||||
"""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 = 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"})
|
||||
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):
|
||||
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):
|
||||
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)
|
||||
config = screens.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):
|
||||
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):
|
||||
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"}}',
|
||||
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 = 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):
|
||||
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": 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, game):
|
||||
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):
|
||||
"""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)
|
||||
html = screens.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):
|
||||
"""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 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)
|
||||
html = screens.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):
|
||||
"""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 = 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)
|
||||
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"
|
||||
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):
|
||||
"""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
|
||||
besoin de regrouper score/minuteur à masquer, il n'existe QUE plein
|
||||
écran)."""
|
||||
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})
|
||||
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)
|
||||
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"
|
||||
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):
|
||||
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)
|
||||
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):
|
||||
"""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)
|
||||
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):
|
||||
"""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)
|
||||
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):
|
||||
"""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
|
||||
inconnu à la création de l'objet)."""
|
||||
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)
|
||||
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):
|
||||
"""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
|
||||
conteneur EN JEU seulement (toujours vide côté rendu serveur)."""
|
||||
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)
|
||||
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
|
||||
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 = 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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
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
|
||||
|
||||
|
||||
def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeholder(tmp_game_slug_cleanup):
|
||||
"""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/js/play/dialogue-box-controller.js)."""
|
||||
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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
html = screens.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):
|
||||
""""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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
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):
|
||||
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)
|
||||
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):
|
||||
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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
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):
|
||||
"""É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 = 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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
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):
|
||||
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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
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):
|
||||
"""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 = 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)
|
||||
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):
|
||||
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"})
|
||||
payload = screens.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):
|
||||
"""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
|
||||
plein viewport (voir .playScene--quizFullscreen), pas seulement le
|
||||
widget."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
payload = screens.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 = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
|
||||
payload = screens.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, tmp_game_slug_cleanup):
|
||||
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})
|
||||
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, tmp_game_slug_cleanup):
|
||||
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)
|
||||
# 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, tmp_game_slug_cleanup):
|
||||
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)
|
||||
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 screens.QUIZ_BOX_DIALOG_TEMPLATES:
|
||||
assert f"selectQuizBoxDialogTemplate({obj_id}, '{template}')" in html
|
||||
|
||||
|
||||
def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(client, tmp_game_slug_cleanup):
|
||||
"""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 = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
screens.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 screens.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, tmp_game_slug_cleanup):
|
||||
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)
|
||||
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, tmp_game_slug_cleanup):
|
||||
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})
|
||||
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, tmp_game_slug_cleanup):
|
||||
"""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 = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
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)
|
||||
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, tmp_game_slug_cleanup):
|
||||
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):
|
||||
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 = 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)
|
||||
html_before = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "sceneCameraFrame" in html_before
|
||||
|
||||
quiz_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
|
||||
screens.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, tmp_game_slug_cleanup):
|
||||
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)
|
||||
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)
|
||||
assert "sceneCameraFrame" in html
|
||||
Reference in New Issue
Block a user