Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur Support de formation (voir docs/plan/PLAN.md), sans toucher a l'architecture en couches existante : - screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D, avant l'arrivee d'un second "moteur" cote document) : ~85 imports corriges, contrat import-linter mis a jour, meme forme de couches. - routes/, scripts/, static/, templates/, tests/ : tout ce qui est propre au jeu 2D deplace dans un sous-dossier game/ de chacun (routes/game/, static/game/, templates/game/, tests/game/, scripts/game/) ; ce qui est partage par le site (auth, onboarding, dashboard, uploads, db/) reste a la racine de chaque dossier. Un sous-dossier document/ (vide) cree dans chacun pour le futur chantier. - styles/ volontairement inchange : les 3 fichiers sources sont concatenes en un seul static/style.css charge par tout le site, scinder leur CONTENU (editeur vs partage) serait un refactor CSS distinct, pas un deplacement mecanique. - Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en profondeur : copie des assets, URLs d'icones relatives a static/style.css (qui ne bouge pas), manifeste, wrapper SCORM. - Deux regressions d'un sweep de renommage anterieur corrigees au passage (screens.js/screens/scene-objects incorrectement convertis en game_engine.js/game_engine/scene-objects dans des commentaires). - Effet de bord Windows decouvert et corrige : git mv + Path.write_text convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers normalises en LF. - .eslintrc.json/package.json : uniquement les chemins de glob mis a jour (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7 reste volontairement non committee (package-lock.json restaure a la version precedente). Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit, lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ; demarrage serveur + requetes HTTP manuelles confirmant que les assets deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien. SKIP=djlint : backlog H021 (styles inline) deja documente comme dette assumee dans CODE_QUALITY.md section 6, aucun template touche par ce commit au-dela d'un deplacement de fichier. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
259 lines
12 KiB
Python
259 lines
12 KiB
Python
"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir
|
||
game_engine/rendering/dialogue_box_style.py, game_engine/scenes/add_scene_object.py
|
||
kind="dialogue_box") : affiche automatiquement, en jeu, la réplique en
|
||
cours d'un dialogue déclenché par une règle de collision "dialogue" (voir
|
||
static/game/js/play/dialogue-box-controller.js) — header/body/footer
|
||
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
|
||
game_engine/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
|
||
l'éditeur de dialogue d'un déclencheur (game_engine/rendering/scene_object_names.py)."""
|
||
|
||
from collections.abc import Callable
|
||
from typing import Any
|
||
|
||
from flask.testing import FlaskClient
|
||
|
||
import db
|
||
import game_engine
|
||
from tests.conftest import not_none
|
||
|
||
|
||
def _create_jeu2d_game(tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_dialogue_box") -> Any:
|
||
slug = tmp_game_slug_cleanup(db.create_game(name))
|
||
screen_id = game_engine.create_screen(slug, "Scène 1", kind="jeu_2d")
|
||
return slug, screen_id
|
||
|
||
|
||
def test_add_dialogue_box_has_sensible_default_size(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="dialogue_box")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
assert obj["kind"] == "dialogue_box"
|
||
assert obj["width"] == 320
|
||
assert obj["height"] == 150
|
||
|
||
|
||
def test_dialogue_box_default_style(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="dialogue_box")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
style = game_engine.resolve_dialogue_box_style(obj)
|
||
assert style == game_engine.DEFAULT_DIALOGUE_BOX_STYLE
|
||
|
||
|
||
def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="dialogue_box")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
html = game_engine.render_scene_object(obj)
|
||
assert "dialogueBoxHeader" in html
|
||
assert "dialogueBoxBody" in html
|
||
assert "dialogueBoxFooter" in html
|
||
assert f'data-object-id="{obj_id}"' in html
|
||
# Bug corrigé : le footer n'avait PAS data-dialogue-role="footer" (seul
|
||
# son bouton "Suivant" interne l'avait, sous "next-btn") — le JS
|
||
# (forgeResetDialogueBoxFooter, static/game/js/play/dialogue-box-controller.js)
|
||
# le cherche par ce sélecteur pour y (re)poser le bouton "Suivant".
|
||
assert 'data-dialogue-role="header"' in html
|
||
assert 'data-dialogue-role="body"' in html
|
||
assert 'data-dialogue-role="footer"' in html
|
||
|
||
|
||
def test_set_scene_object_dialogue_box_style_persists_and_sanitizes(
|
||
tmp_game_slug_cleanup: Callable[[str], str],
|
||
) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="dialogue_box")
|
||
style = not_none(
|
||
game_engine.set_scene_object_dialogue_box_style(
|
||
slug,
|
||
obj_id,
|
||
{
|
||
"font_family": "Georgia",
|
||
"font_size": 999,
|
||
"font_weight": "bold",
|
||
"text_color": "#ff0000",
|
||
"header_bg": "#000000",
|
||
"unknown_key": "ignored",
|
||
},
|
||
)
|
||
)
|
||
assert style["font_family"] == "Georgia"
|
||
assert style["font_size"] == 48 # bornée au maximum autorisé
|
||
assert style["font_weight"] == "bold"
|
||
assert style["text_color"] == "#ff0000"
|
||
assert style["header_bg"] == "#000000"
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
assert game_engine.resolve_dialogue_box_style(obj) == style
|
||
|
||
|
||
def test_dialogue_box_route_persists_style(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="dialogue_box")
|
||
resp = client.post(
|
||
f"/game/{slug}/scene-objects/{obj_id}/dialogue-box-style",
|
||
data='{"style": {"header_bg": "#123456"}}',
|
||
content_type="application/json",
|
||
)
|
||
assert resp.get_json()["style"]["header_bg"] == "#123456"
|
||
|
||
|
||
def test_dialogue_box_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
|
||
resp = client.post(
|
||
f"/game/{game}/scene-objects/999999/dialogue-box-style", data='{"style": {}}', content_type="application/json"
|
||
)
|
||
assert resp.status_code == 404
|
||
|
||
|
||
def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
game_engine.add_scene_object(slug, screen_id, kind="dialogue_box")
|
||
payload = game_engine.full_game_payload(slug)
|
||
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||
box = next(o for o in scene["elements"] if o["kind"] == "dialogue_box")
|
||
assert "collision_rules" not in box
|
||
assert "collision" not in box
|
||
assert box["dialogue_box_style"] == game_engine.DEFAULT_DIALOGUE_BOX_STYLE
|
||
|
||
|
||
def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(
|
||
tmp_game_slug_cleanup: Callable[[str], str],
|
||
) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
|
||
rules = [
|
||
{
|
||
"trigger": "collision",
|
||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True},
|
||
}
|
||
]
|
||
game_engine.set_scene_object_collision_rules(slug, obj_id, game_engine.sanitize_collision_rules(rules))
|
||
payload = game_engine.full_game_payload(slug)
|
||
assert payload["completable_dialogue_ids"] == ["d_1"]
|
||
|
||
|
||
# ---------- Nom d'un personnage ("ℹ️ Informations") ----------
|
||
|
||
|
||
def test_scene_object_name_is_none_by_default(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
assert obj["name"] is None
|
||
|
||
|
||
def test_set_scene_object_name_persists_and_trims(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
|
||
game_engine.set_scene_object_name(slug, obj_id, " Le Sorcier ")
|
||
assert not_none(game_engine.get_scene_object(slug, obj_id))["name"] == "Le Sorcier"
|
||
|
||
|
||
def test_set_scene_object_name_blank_clears_it(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
|
||
game_engine.set_scene_object_name(slug, obj_id, "Le Sorcier")
|
||
game_engine.set_scene_object_name(slug, obj_id, " ")
|
||
assert not_none(game_engine.get_scene_object(slug, obj_id))["name"] is None
|
||
|
||
|
||
def test_scene_object_name_route_persists(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
|
||
resp = client.post(
|
||
f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json"
|
||
)
|
||
assert resp.status_code == 200
|
||
assert not_none(game_engine.get_scene_object(slug, obj_id))["name"] == "Le Sorcier"
|
||
|
||
|
||
def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_kind(
|
||
tmp_game_slug_cleanup: Callable[[str], str],
|
||
) -> None:
|
||
""" "il peut sélectionner le nom du personnage qui parle" -> étendu à
|
||
N'IMPORTE QUEL objet de scène nommé (pas seulement un personnage),
|
||
voir game_engine/rendering/scene_object_names.py."""
|
||
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_object_names"))
|
||
scene_a = game_engine.create_screen(slug, "Scène A", kind="jeu_2d")
|
||
scene_b = game_engine.create_screen(slug, "Scène B", kind="jeu_2d")
|
||
zed_id = game_engine.add_scene_object(slug, scene_a, kind="personnage")
|
||
game_engine.set_scene_object_name(slug, zed_id, "Zed le forgeron")
|
||
anna_id = game_engine.add_scene_object(slug, scene_b, kind="personnage")
|
||
game_engine.set_scene_object_name(slug, anna_id, "Anna")
|
||
game_engine.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré
|
||
decor_id = game_engine.add_scene_object(slug, scene_a, kind="decor") # nommé -> INCLUS (pas réservé au personnage)
|
||
game_engine.set_scene_object_name(slug, decor_id, "Un coffre")
|
||
# widget d'UI, jamais un "qui parle"
|
||
dialogue_box_id = game_engine.add_scene_object(slug, scene_a, kind="dialogue_box")
|
||
game_engine.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur")
|
||
|
||
assert game_engine.list_named_scene_object_names(slug) == ["Anna", "Un coffre", "Zed le forgeron"]
|
||
|
||
|
||
# ---------- Widgets "❓ Boîte à quiz" / "🏆 Score" ----------
|
||
|
||
|
||
def test_add_quiz_box_has_sensible_default_size(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
assert obj["kind"] == "quiz_box"
|
||
assert obj["width"] == 340
|
||
assert obj["height"] == 240
|
||
|
||
|
||
def test_add_score_widget_has_sensible_default_size(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="score_widget")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
assert obj["kind"] == "score_widget"
|
||
assert obj["width"] == 140
|
||
assert obj["height"] == 50
|
||
|
||
|
||
def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
html = game_engine.render_scene_object(obj)
|
||
assert 'data-quiz-role="header"' in html # nom de l'objet déclencheur, rempli en jeu
|
||
assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps
|
||
assert 'data-quiz-role="choices"' in html
|
||
assert f'data-object-id="{obj_id}"' in html
|
||
assert "quizBoxFooter" not in html # pas de pied, une question se résout au clic
|
||
|
||
|
||
def test_render_score_widget_shows_zero_by_default(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
obj_id = game_engine.add_scene_object(slug, screen_id, kind="score_widget")
|
||
obj = not_none(game_engine.get_scene_object(slug, obj_id))
|
||
html = game_engine.render_scene_object(obj)
|
||
assert 'data-score-role="value"' in html
|
||
assert f'data-object-id="{obj_id}"' in html
|
||
|
||
|
||
def test_quiz_box_and_score_widget_share_the_same_style_system_as_dialogue_box(
|
||
tmp_game_slug_cleanup: Callable[[str], str],
|
||
) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
quiz_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
|
||
style = not_none(game_engine.set_scene_object_dialogue_box_style(slug, quiz_id, {"header_bg": "#abcdef"}))
|
||
assert style["header_bg"] == "#abcdef"
|
||
obj = not_none(game_engine.get_scene_object(slug, quiz_id))
|
||
assert game_engine.resolve_dialogue_box_style(obj)["header_bg"] == "#abcdef"
|
||
|
||
|
||
def test_quiz_box_and_score_widget_excluded_from_collision_rules_payload(
|
||
tmp_game_slug_cleanup: Callable[[str], str],
|
||
) -> None:
|
||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||
game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
|
||
game_engine.add_scene_object(slug, screen_id, kind="score_widget")
|
||
payload = game_engine.full_game_payload(slug)
|
||
scene = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||
quiz = next(o for o in scene["elements"] if o["kind"] == "quiz_box")
|
||
score = next(o for o in scene["elements"] if o["kind"] == "score_widget")
|
||
for widget in (quiz, score):
|
||
assert "collision_rules" not in widget
|
||
assert "collision" not in widget
|
||
assert widget["dialogue_box_style"] == game_engine.DEFAULT_DIALOGUE_BOX_STYLE
|