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

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

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

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

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

259 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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