Files
williamandClaude Sonnet 5 c57420c8c9 Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 16:06:15 +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 screens.*/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 screens
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 = screens.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(screens.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(screens.BACKGROUND_LIBRARY))
result = dispatch_tool(slug, screen_id, admin_id, "add_scene_object", {"kind": "fond", "background_slug": bg_slug})
obj = not_none(screens.get_scene_object(slug, result["object_id"]))
assert obj["attributes"]["src"] == screens.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 (screens.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(screens.ADMIN_ONLY_BACKGROUND_SLUGS))
result = dispatch_tool(slug, screen_id, user_id, "add_scene_object", {"kind": "fond", "background_slug": bg_slug})
obj = not_none(screens.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(screens.ADMIN_ONLY_CHARACTER_SLUGS))
result = dispatch_tool(
slug, screen_id, user_id, "add_scene_object", {"kind": "personnage", "forge_character": char_slug}
)
obj = not_none(screens.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(screens.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(screens.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 = screens.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(screens.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 = screens.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(screens.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 = screens.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(screens.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 = screens.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(screens.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 = screens.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(screens.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 = screens.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(screens.get_scene_object(slug, object_id))
assert screens.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 = screens.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(screens.get_scene_object(slug, object_id))
settings = screens.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
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 = not_none(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: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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 = not_none(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: 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 = screens.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(screens.get_scene_object(slug, object_id))
assert screens.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 = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_collision_rules(
slug,
object_id,
screens.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(screens.get_scene_object(slug, object_id))
assert screens.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 = screens.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(screens.get_scene_object(slug, object_id))
assert screens.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 = screens.get_screen(slug, screen_id)
assert screens.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)
screens.set_screen_triggers(
slug,
screen_id,
screens.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 = screens.get_screen(slug, screen_id)
assert screens.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 = screens.add_scene_object(slug, screen_id, kind="decor")
screens.set_scene_object_collision_rules(
slug,
object_id,
screens.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(screens.get_scene_object(slug, object_id))
assert screens.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 = screens.add_scene_object(slug, screen_id, kind="decor")
screens.set_scene_object_collision_rules(
slug,
object_id,
screens.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(screens.get_scene_object(slug, object_id))
assert screens.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)
screens.set_screen_triggers(
slug,
screen_id,
screens.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 = screens.get_screen(slug, screen_id)
assert screens.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)
screens.set_screen_triggers(
slug,
screen_id,
screens.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 = screens.get_screen(slug, screen_id)
assert screens.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(screens.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 screens/rendering/collision_rules.py, screens/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 screens.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 screens.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 screens.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 screens.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 screens.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 screens.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 screens.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(screens.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(screens.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(screens.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(screens.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(screens.QUIZ_BOX_PAGE_TEMPLATES)