"""Widget d'interface "🖥️ Interface -> 💬 Boîte de dialogue" (voir screens/rendering/dialogue_box_style.py, screens/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/js/play/dialogue-box-controller.js) — header/body/footer stylisables. Et le nom d'un personnage ("ℹ️ Informations", screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans l'éditeur de dialogue d'un déclencheur (screens/rendering/scene_object_names.py).""" from collections.abc import Callable from typing import Any from flask.testing import FlaskClient import db import screens from tests.conftest import not_none def _create_jeu2d_game(tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_dialogue_box") -> Any: slug = tmp_game_slug_cleanup(db.create_game(name)) screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d") return slug, screen_id 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 = screens.add_scene_object(slug, screen_id, kind="dialogue_box") obj = not_none(screens.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 = screens.add_scene_object(slug, screen_id, kind="dialogue_box") obj = not_none(screens.get_scene_object(slug, obj_id)) style = screens.resolve_dialogue_box_style(obj) assert style == screens.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 = screens.add_scene_object(slug, screen_id, kind="dialogue_box") obj = not_none(screens.get_scene_object(slug, obj_id)) html = screens.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/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 = screens.add_scene_object(slug, screen_id, kind="dialogue_box") style = not_none( screens.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(screens.get_scene_object(slug, obj_id)) assert screens.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 = screens.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) screens.add_scene_object(slug, screen_id, kind="dialogue_box") 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"] == "dialogue_box") assert "collision_rules" not in box assert "collision" not in box assert box["dialogue_box_style"] == screens.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 = screens.add_scene_object(slug, screen_id, kind="personnage") rules = [ { "trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True}, } ] screens.set_scene_object_collision_rules(slug, obj_id, screens.sanitize_collision_rules(rules)) payload = screens.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 = screens.add_scene_object(slug, screen_id, kind="personnage") obj = not_none(screens.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 = screens.add_scene_object(slug, screen_id, kind="personnage") screens.set_scene_object_name(slug, obj_id, " Le Sorcier ") assert not_none(screens.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 = screens.add_scene_object(slug, screen_id, kind="personnage") screens.set_scene_object_name(slug, obj_id, "Le Sorcier") screens.set_scene_object_name(slug, obj_id, " ") assert not_none(screens.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 = screens.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(screens.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 screens/rendering/scene_object_names.py.""" slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_object_names")) scene_a = screens.create_screen(slug, "Scène A", kind="jeu_2d") scene_b = screens.create_screen(slug, "Scène B", kind="jeu_2d") zed_id = screens.add_scene_object(slug, scene_a, kind="personnage") screens.set_scene_object_name(slug, zed_id, "Zed le forgeron") anna_id = screens.add_scene_object(slug, scene_b, kind="personnage") screens.set_scene_object_name(slug, anna_id, "Anna") screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # nommé -> INCLUS (pas réservé au personnage) screens.set_scene_object_name(slug, decor_id, "Un coffre") dialogue_box_id = screens.add_scene_object(slug, scene_a, kind="dialogue_box") # widget d'UI, jamais un "qui parle" screens.set_scene_object_name(slug, dialogue_box_id, "Boîte nommée par erreur") assert screens.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 = screens.add_scene_object(slug, screen_id, kind="quiz_box") obj = not_none(screens.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 = screens.add_scene_object(slug, screen_id, kind="score_widget") obj = not_none(screens.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 = screens.add_scene_object(slug, screen_id, kind="quiz_box") obj = not_none(screens.get_scene_object(slug, obj_id)) html = screens.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 = screens.add_scene_object(slug, screen_id, kind="score_widget") obj = not_none(screens.get_scene_object(slug, obj_id)) html = screens.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 = screens.add_scene_object(slug, screen_id, kind="quiz_box") style = not_none(screens.set_scene_object_dialogue_box_style(slug, quiz_id, {"header_bg": "#abcdef"})) assert style["header_bg"] == "#abcdef" obj = not_none(screens.get_scene_object(slug, quiz_id)) assert screens.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) screens.add_scene_object(slug, screen_id, kind="quiz_box") screens.add_scene_object(slug, screen_id, kind="score_widget") payload = screens.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"] == screens.DEFAULT_DIALOGUE_BOX_STYLE