Files
Forge-Engine/tests/game/test_ai_tools.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

659 lines
28 KiB
Python

"""Couche outils IA (voir plan Phase 1, §4 — ai/tools.py) : chaque
dispatch doit appeler DIRECTEMENT la fonction game_engine.*/db.* existante
(jamais une structure parallèle), et les schémas doivent suivre les
constantes SOURCE plutôt qu'une copie littérale (principe validé avec
l'utilisateur : une nouvelle valeur d'énum doit suivre automatiquement)."""
from collections.abc import Callable
from typing import Any
import pytest
from flask.testing import FlaskClient
import ai
import auth
import db
import game_engine
from ai.tools import TOOLS, dispatch_tool
from tests.conftest import not_none
def _tool(name: str) -> Any:
return next(t for t in TOOLS if t["name"] == name)
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_ai_tools"
) -> Any:
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
screen_id = game_engine.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def test_dispatch_add_scene_object_calls_the_real_function(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
result = dispatch_tool(slug, screen_id, 1, "add_scene_object", {"kind": "decor", "image_url": "/assets/1/x.png"})
obj = not_none(game_engine.get_scene_object(slug, result["object_id"]))
assert obj["kind"] == "decor"
assert obj["attributes"]["src"] == "/assets/1/x.png"
def test_dispatch_add_scene_object_with_background_slug_uses_the_library_image(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
admin_id = sess["user_id"]
bg_slug = next(iter(game_engine.BACKGROUND_LIBRARY))
result = dispatch_tool(slug, screen_id, admin_id, "add_scene_object", {"kind": "fond", "background_slug": bg_slug})
obj = not_none(game_engine.get_scene_object(slug, result["object_id"]))
assert obj["attributes"]["src"] == game_engine.BACKGROUND_LIBRARY[bg_slug]["url"]
def test_dispatch_add_scene_object_raises_for_an_unknown_background_slug(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé : un slug inventé/mal orthographié par Claude était
ignoré en silence (game_engine.add_scene_object, pensé pour un <select>
HTML qui ne propose que des valeurs valides) — Ruby annonçait alors
un fond posé qui n'apparaissait jamais en jeu. Lever une erreur ici
la remonte comme résultat d'outil (voir ai/chat.py), visible par
Claude plutôt qu'un faux succès."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
admin_id = sess["user_id"]
try:
dispatch_tool(
slug, screen_id, admin_id, "add_scene_object", {"kind": "fond", "background_slug": "ne-existe-pas"}
)
raise AssertionError("devrait lever ValueError")
except ValueError:
pass
def test_dispatch_add_scene_object_raises_for_an_unknown_forge_character(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
admin_id = sess["user_id"]
try:
dispatch_tool(
slug, screen_id, admin_id, "add_scene_object", {"kind": "personnage", "forge_character": "ne-existe-pas"}
)
raise AssertionError("devrait lever ValueError")
except ValueError:
pass
def test_dispatch_add_scene_object_forbids_admin_only_background_for_a_non_admin(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with user_client.session_transaction() as sess:
user_id = sess["user_id"]
bg_slug = next(iter(game_engine.ADMIN_ONLY_BACKGROUND_SLUGS))
result = dispatch_tool(slug, screen_id, user_id, "add_scene_object", {"kind": "fond", "background_slug": bg_slug})
obj = not_none(game_engine.get_scene_object(slug, result["object_id"]))
# Retombe sur le comportement par défaut (aucun src) plutôt que de
# poser l'image admin-only — jamais une erreur qui casse le tour de chat.
assert "src" not in obj["attributes"]
def test_dispatch_add_scene_object_forbids_admin_only_character_for_a_non_admin(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with user_client.session_transaction() as sess:
user_id = sess["user_id"]
char_slug = next(iter(game_engine.ADMIN_ONLY_CHARACTER_SLUGS))
result = dispatch_tool(
slug, screen_id, user_id, "add_scene_object", {"kind": "personnage", "forge_character": char_slug}
)
obj = not_none(game_engine.get_scene_object(slug, result["object_id"]))
import json
assert json.loads(obj["attributes"]["_personnage_data"])["forge_character"] != char_slug
def test_add_scene_object_forge_character_enum_matches_the_source_constant() -> None:
schema = _tool("add_scene_object")["input_schema"]["properties"]["forge_character"]
assert set(schema["enum"]) == set(game_engine.SPRITE_LIBRARY)
def test_add_scene_object_background_slug_enum_matches_the_source_constant() -> None:
schema = _tool("add_scene_object")["input_schema"]["properties"]["background_slug"]
assert set(schema["enum"]) == set(game_engine.BACKGROUND_LIBRARY)
def test_dispatch_set_object_geometry_updates_position_and_size(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor")
dispatch_tool(
slug, screen_id, 1, "set_object_geometry", {"object_id": object_id, "x": 10, "y": 20, "width": 64, "height": 96}
)
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (10, 20, 64, 96)
def test_dispatch_set_object_name_calls_the_real_function(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
dispatch_tool(slug, screen_id, 1, "set_object_name", {"object_id": object_id, "name": "Aka"})
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert obj["name"] == "Aka"
def test_dispatch_set_object_geometry_clamps_a_position_outside_the_camera(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé à plusieurs reprises malgré des instructions explicites
ET un avertissement dans le résultat de l'outil : l'IA n'arrivait
toujours pas à se replacer correctement elle-même. Le moteur RAMÈNE
maintenant automatiquement la position dans le cadre (voir
_clamp_to_camera, ai/tools.py) — garanti dès le premier appel, sans
compter sur une correction ultérieure de l'IA."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup) # écran 960x540 par défaut
object_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
result = dispatch_tool(
slug,
screen_id,
1,
"set_object_geometry",
{"object_id": object_id, "x": 950, "y": 550, "width": 200, "height": 300},
)
assert "note" in result
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert obj["x"] + obj["width"] <= 960
assert obj["y"] + obj["height"] <= 540
def test_dispatch_set_object_geometry_no_note_when_already_inside_the_camera(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
result = dispatch_tool(
slug,
screen_id,
1,
"set_object_geometry",
{"object_id": object_id, "x": 400, "y": 200, "width": 128, "height": 128},
)
assert "note" not in result
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert (obj["x"], obj["y"]) == (400, 200)
def test_dispatch_set_object_geometry_never_clamps_a_fond_bigger_than_the_camera(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="fond")
result = dispatch_tool(
slug,
screen_id,
1,
"set_object_geometry",
{"object_id": object_id, "x": 0, "y": 0, "width": 2000, "height": 1000},
)
assert "note" not in result
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert (obj["width"], obj["height"]) == (2000, 1000)
def test_dispatch_set_object_role_updates_the_role(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
dispatch_tool(slug, screen_id, 1, "set_object_role", {"object_id": object_id, "role": "ennemie"})
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert game_engine.resolve_personnage_role(obj) == "ennemie"
def test_dispatch_set_object_collision_updates_the_settings(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor")
dispatch_tool(
slug, screen_id, 1, "set_object_collision", {"object_id": object_id, "enabled": False, "shape": "cercle"}
)
obj = not_none(game_engine.get_scene_object(slug, object_id))
settings = game_engine.resolve_collision_settings(obj)
assert settings["enabled"] is False
assert settings["shape"] == "cercle"
def test_dispatch_set_quiz_box_config_updates_the_settings(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
(plein écran/minuteur/modèle d'une "❓ Boîte à quiz", voir
game_engine/rendering/quiz_box_config.py) — pas seulement l'éditeur
manuel."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.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 = not_none(game_engine.get_scene_object(slug, object_id))
config = game_engine.resolve_quiz_box_config(obj)
assert config == {
"fullscreen": True,
"timer_mode": "question",
"timer_seconds": 15,
"dialog_template": "defaut",
"page_template": "manga",
}
def test_dispatch_set_quiz_box_config_defaults_to_no_fullscreen_no_timer(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="quiz_box")
dispatch_tool(slug, screen_id, 1, "set_quiz_box_config", {"object_id": object_id})
obj = not_none(game_engine.get_scene_object(slug, object_id))
config = game_engine.resolve_quiz_box_config(obj)
assert config == game_engine.DEFAULT_QUIZ_BOX_CONFIG
def test_dispatch_create_global_variable_calls_db_directly(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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"}
)
assert result["variable_id"]
names = [v["name"] for v in db.list_global_variables(slug)]
assert "score_ia" in names
def test_dispatch_set_collision_rules_sanitizes_and_stores(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
rules = [{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}]
dispatch_tool(slug, screen_id, 1, "set_collision_rules", {"object_id": object_id, "rules": rules})
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert game_engine.resolve_collision_rules(obj)[0]["action"]["type"] == "dialogue"
def test_dispatch_append_action_to_trigger_extends_an_existing_chain(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="personnage")
game_engine.set_scene_object_collision_rules(
slug,
object_id,
game_engine.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
]
),
)
result = dispatch_tool(
slug,
screen_id,
1,
"append_action_to_trigger",
{
"object_id": object_id,
"after_id": "d_1",
"action": {
"type": "variable",
"target_variable": "score_ia",
"data_operation": "incrementer",
"data_value": "1",
},
},
)
assert result["ok"] is True
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert game_engine.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score_ia"
def test_dispatch_set_collision_rules_accepts_clic_and_survol_triggers(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor")
rules = [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": object_id, "valeur": "toggle"}}]
dispatch_tool(slug, screen_id, 1, "set_collision_rules", {"object_id": object_id, "rules": rules})
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert game_engine.resolve_collision_rules(obj) == rules
def test_dispatch_set_screen_triggers_sanitizes_and_stores(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
rules = [
{"trigger": "affichage", "action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"}}
]
result = dispatch_tool(slug, screen_id, 1, "set_screen_triggers", {"rules": rules})
assert result["ok"] is True
screen = game_engine.get_screen(slug, screen_id)
assert game_engine.resolve_screen_triggers(screen) == rules
def test_dispatch_append_action_to_screen_trigger_extends_an_existing_chain(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
game_engine.set_screen_triggers(
slug,
screen_id,
game_engine.sanitize_screen_triggers(
[
{"trigger": "affichage", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
]
),
)
result = dispatch_tool(
slug,
screen_id,
1,
"append_action_to_screen_trigger",
{
"after_id": "d_1",
"action": {
"type": "variable",
"target_variable": "score_ia",
"data_operation": "incrementer",
"data_value": "1",
},
},
)
assert result["ok"] is True
screen = game_engine.get_screen(slug, screen_id)
assert game_engine.resolve_screen_triggers(screen)[0]["action"]["then"]["target_variable"] == "score_ia"
def test_dispatch_remove_trigger_action_removes_a_leaf(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor")
game_engine.set_scene_object_collision_rules(
slug,
object_id,
game_engine.sanitize_collision_rules(
[
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
]
),
)
result = dispatch_tool(slug, screen_id, 1, "remove_trigger_action", {"object_id": object_id, "leaf_id": "d_1"})
assert result["ok"] is True
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert game_engine.resolve_collision_rules(obj) == []
def test_dispatch_move_trigger_action_swaps_a_leaf_up(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor")
game_engine.set_scene_object_collision_rules(
slug,
object_id,
game_engine.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
),
)
result = dispatch_tool(
slug, screen_id, 1, "move_trigger_action", {"object_id": object_id, "leaf_id": "d_1", "direction": "up"}
)
assert result["ok"] is True
obj = not_none(game_engine.get_scene_object(slug, object_id))
assert game_engine.resolve_collision_rules(obj)[0]["action"]["type"] == "dialogue"
def test_dispatch_remove_screen_trigger_action_removes_a_leaf(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
game_engine.set_screen_triggers(
slug,
screen_id,
game_engine.sanitize_screen_triggers(
[
{"trigger": "affichage", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
]
),
)
result = dispatch_tool(slug, screen_id, 1, "remove_screen_trigger_action", {"leaf_id": "d_1"})
assert result["ok"] is True
screen = game_engine.get_screen(slug, screen_id)
assert game_engine.resolve_screen_triggers(screen) == []
def test_dispatch_move_screen_trigger_action_swaps_a_leaf_up(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
game_engine.set_screen_triggers(
slug,
screen_id,
game_engine.sanitize_screen_triggers(
[
{
"trigger": "affichage",
"action": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
),
)
result = dispatch_tool(slug, screen_id, 1, "move_screen_trigger_action", {"leaf_id": "d_1", "direction": "up"})
assert result["ok"] is True
screen = game_engine.get_screen(slug, screen_id)
assert game_engine.resolve_screen_triggers(screen)[0]["action"]["type"] == "dialogue"
def test_dispatch_tool_raises_for_an_unknown_tool_name(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
try:
dispatch_tool(slug, screen_id, 1, "does_not_exist", {})
raise AssertionError("devrait lever KeyError")
except KeyError:
pass
class _FakeDownloadResponse:
"""Mirroir minimal de requests.Response — voir
test_dispatch_add_generated_image_* ci-dessous (jamais de vrai appel
réseau, ni vers Scenario ni pour télécharger l'image résultante)."""
content = b"fake-generated-image-bytes"
def raise_for_status(self) -> None:
pass
def test_dispatch_add_generated_image_saves_to_my_assets_and_poses_it(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Scenario (mocké) -> "Mes assets" (source="ia") -> posée sur
l'écran via LE MÊME add_scene_object que Phase 1 — jamais un chemin
parallèle (voir plan Phase 2, §4)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
user_id = sess["user_id"]
monkeypatch.setattr("ai.tools.generate_image_url", lambda prompt: "https://scenario.example/image.png")
monkeypatch.setattr("ai.tools.requests.get", lambda url, timeout=None: _FakeDownloadResponse())
result = dispatch_tool(
slug, screen_id, user_id, "add_generated_image", {"kind": "decor", "prompt": "un bureau moderne"}
)
obj = not_none(game_engine.get_scene_object(slug, result["object_id"]))
assert obj["kind"] == "decor"
assert f"/assets/{user_id}/" in obj["attributes"]["src"]
assets = auth.list_user_assets(user_id)
assert any(a["id"] == result["asset_id"] and a["source"] == "ia" for a in assets)
auth.delete_user_asset(result["asset_id"], user_id)
def test_dispatch_add_generated_image_surfaces_scenario_errors_as_a_tool_error(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""Le chat (ai/chat.py) attrape déjà les exceptions de dispatch_tool
au niveau de la boucle — mais un test direct de la propagation
évite une régression silencieuse si ce comportement change."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
def _raise(prompt: Any) -> None:
raise ai.ScenarioNotConfiguredError("clé absente")
monkeypatch.setattr("ai.tools.generate_image_url", _raise)
try:
dispatch_tool(slug, screen_id, 1, "add_generated_image", {"kind": "decor", "prompt": "un bureau"})
raise AssertionError("devrait lever ScenarioNotConfiguredError")
except ai.ScenarioNotConfiguredError:
pass
# ---------- Anti-dérive : les énums viennent des constantes SOURCE ----------
# (voir game_engine/rendering/collision_rules.py, game_engine/labels/data_operations.py,
# db/constants.py) — jamais recopiées en dur dans ai/tools.py. Si ce test
# échoue après l'ajout d'une nouvelle valeur côté moteur, c'est que
# quelqu'un a remplacé la référence par une copie littérale : à corriger
# dans ai/tools.py, pas dans ce test.
def test_set_collision_rules_action_type_enum_matches_the_source_constant() -> None:
from game_engine.rendering.collision_rules import ACTION_TYPES
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
assert action_schema["properties"]["type"]["enum"] == list(ACTION_TYPES)
def test_set_collision_rules_trigger_enum_matches_the_source_constant() -> None:
from game_engine.rendering.collision_rules import TRIGGER_TYPES
rules_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]
assert rules_schema["properties"]["trigger"]["enum"] == list(TRIGGER_TYPES)
def test_set_screen_triggers_trigger_enum_matches_the_source_constant() -> None:
from game_engine.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
rules_schema = _tool("set_screen_triggers")["input_schema"]["properties"]["rules"]["items"]
assert rules_schema["properties"]["trigger"]["enum"] == list(TRIGGER_TYPES_SCREEN)
def test_set_collision_rules_video_mode_enum_matches_the_source_constant() -> None:
from game_engine.labels.video_modes import VIDEO_MODE_LABELS
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
assert set(action_schema["properties"]["mode"]["enum"]) == set(VIDEO_MODE_LABELS)
def test_set_collision_rules_condition_operator_enum_matches_the_source_constant() -> None:
from game_engine.rendering.collision_rules import CONDITION_OPERATOR_KEYS
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
assert set(action_schema["properties"]["operateur"]["enum"]) == CONDITION_OPERATOR_KEYS
def test_set_collision_rules_data_operation_enum_matches_the_source_constant() -> None:
from game_engine.labels.data_operations import DATA_OPERATION_LABELS
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
assert set(action_schema["properties"]["data_operation"]["enum"]) == set(DATA_OPERATION_LABELS)
def test_append_action_to_trigger_leaf_type_enum_matches_the_source_constant() -> None:
from game_engine.rendering.collision_rules import LEAF_ACTION_TYPES
leaf_schema = _tool("append_action_to_trigger")["input_schema"]["properties"]["action"]
assert leaf_schema["properties"]["type"]["enum"] == list(LEAF_ACTION_TYPES)
def test_append_action_to_trigger_dialogue_description_documents_reward_amount() -> None:
"""Bug corrigé : la forme d'une ligne de dialogue/question n'était pas
décrite au tool, jusqu'à ce que Ruby invente une variable séparée
pour un score de quiz au lieu d'utiliser reward_amount (déjà
automatique, voir db/dialogue_lines.py). Vérifie que la RÉFÉRENCE aux
valeurs valides de reward_type est bien utilisée (pas une copie)."""
from db.dialogue_lines import QUESTION_REWARD_TYPES
leaf_schema = _tool("append_action_to_trigger")["input_schema"]["properties"]["action"]
dialogue_desc = leaf_schema["properties"]["dialogue"]["description"]
assert "reward_amount" in dialogue_desc
assert all(t in dialogue_desc for t in QUESTION_REWARD_TYPES)
def test_create_global_variable_var_type_enum_matches_the_source_constant() -> None:
schema = _tool("create_global_variable")["input_schema"]["properties"]["var_type"]
assert set(schema["enum"]) == set(db.GLOBAL_VARIABLE_TYPES)
def test_set_object_role_enum_matches_the_source_constant() -> None:
schema = _tool("set_object_role")["input_schema"]["properties"]["role"]
assert schema["enum"] == list(game_engine.PERSONNAGE_ROLES)
def test_set_object_collision_shape_enum_matches_the_source_constant() -> None:
schema = _tool("set_object_collision")["input_schema"]["properties"]["shape"]
assert schema["enum"] == list(game_engine.COLLISION_SHAPES)
def test_set_quiz_box_config_timer_mode_enum_matches_the_source_constant() -> None:
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["timer_mode"]
assert schema["enum"] == list(game_engine.TIMER_MODES)
def test_set_quiz_box_config_dialog_template_enum_matches_the_source_constant() -> None:
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["dialog_template"]
assert schema["enum"] == list(game_engine.QUIZ_BOX_DIALOG_TEMPLATES)
def test_set_quiz_box_config_page_template_enum_matches_the_source_constant() -> None:
schema = _tool("set_quiz_box_config")["input_schema"]["properties"]["page_template"]
assert schema["enum"] == list(game_engine.QUIZ_BOX_PAGE_TEMPLATES)