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>
659 lines
28 KiB
Python
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)
|