Ajoute l'assistant IA "Ruby" (Claude + Scenario) et corrige plusieurs bugs de la scène
Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
ab6635eee0
commit
b07b231a61
@@ -24,6 +24,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
_TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_")
|
||||
os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db")
|
||||
os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
|
||||
# "Mes assets" (voir auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR) :
|
||||
# même raison que FORGE_USERS_DB_PATH ci-dessus — jamais écrire dans le
|
||||
# vrai user_assets/ du poste pendant les tests.
|
||||
os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets")
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -37,6 +41,17 @@ from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_co
|
||||
import auth
|
||||
import db
|
||||
|
||||
# Onglet "IA" (voir ai/) : jamais un vrai appel réseau depuis les tests,
|
||||
# même si un .env réel existe sur ce poste avec de vraies clés —
|
||||
# load_dotenv() (core.flask_app, importé ci-dessus) les aurait déjà
|
||||
# chargées dans os.environ à ce stade ; on les retire explicitement pour
|
||||
# que CHAQUE test parte de "IA non configurée" par défaut. Un test qui
|
||||
# veut simuler une IA configurée monkeypatche directement
|
||||
# ai.client.get_client/ai.scenario_client.generate_image_url — jamais via
|
||||
# une vraie clé, quel que soit le contenu du .env local.
|
||||
for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRET", "SCENARIO_MODEL_ID"):
|
||||
os.environ.pop(_ai_env_key, None)
|
||||
|
||||
# Un seul compte admin pour toute la session de tests : les ~80 routes
|
||||
# existantes s'attendent (comportement d'avant l'authentification) à un
|
||||
# accès sans restriction — "admin" est le seul rôle illimité (voir
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""ai/chat.py — la boucle tool-use (voir plan Phase 2, §5). Jamais un
|
||||
vrai appel à l'API Claude ici : client.messages.create est monkeypatché
|
||||
par une fausse classe qui rejoue une séquence de réponses programmée."""
|
||||
import ai
|
||||
import db
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup, name="pytest_ai_chat"):
|
||||
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")
|
||||
conversation_id = screens.create_ia_conversation(slug, screen_id)
|
||||
return slug, screen_id, conversation_id
|
||||
|
||||
|
||||
class _FakeBlock:
|
||||
def __init__(self, type, text=None, id=None, name=None, input=None):
|
||||
self.type = type
|
||||
self.text = text
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.input = input or {}
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, content, stop_reason):
|
||||
self.content = content
|
||||
self.stop_reason = stop_reason
|
||||
|
||||
|
||||
class _FakeMessages:
|
||||
def __init__(self, responses):
|
||||
self._responses = list(responses)
|
||||
self.call_count = 0
|
||||
self.last_kwargs = None
|
||||
|
||||
def create(self, **kwargs):
|
||||
self.call_count += 1
|
||||
self.last_kwargs = kwargs
|
||||
if len(self._responses) > 1:
|
||||
return self._responses.pop(0)
|
||||
return self._responses[0] # rejoue la dernière indéfiniment (voir test de la borne)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, responses):
|
||||
self.messages = _FakeMessages(responses)
|
||||
|
||||
|
||||
def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="Bonjour, que veux-tu créer ?")], "end_turn")])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
assert reply == "Bonjour, que veux-tu créer ?"
|
||||
assert fake_client.messages.call_count == 1
|
||||
|
||||
|
||||
def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
tool_use_response = _FakeResponse(
|
||||
[_FakeBlock("tool_use", id="t1", name="create_global_variable", input={"name": "score_ia", "var_type": "nombre_entier"})],
|
||||
"tool_use",
|
||||
)
|
||||
final_response = _FakeResponse([_FakeBlock("text", text="Variable créée.")], "end_turn")
|
||||
fake_client = _FakeClient([tool_use_response, final_response])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Crée une variable score_ia")
|
||||
assert reply == "Variable créée."
|
||||
names = [v["name"] for v in db.list_global_variables(slug)]
|
||||
assert "score_ia" in names
|
||||
|
||||
|
||||
def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calling_tools(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
always_tool_use = _FakeResponse(
|
||||
[_FakeBlock("tool_use", id="t1", name="create_global_variable", input={"name": "boucle_infinie"})],
|
||||
"tool_use",
|
||||
)
|
||||
fake_client = _FakeClient([always_tool_use])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Fais quelque chose")
|
||||
assert fake_client.messages.call_count == ai.chat._MAX_TOOL_ITERATIONS
|
||||
assert reply == "(pas de réponse textuelle)"
|
||||
|
||||
|
||||
def test_run_chat_turn_explains_when_cut_short_by_max_tokens(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
"""Bug corrigé : max_tokens=4096 pouvait couper Claude EN PLEINE
|
||||
RÉFLEXION sur une demande riche, avant le moindre appel d'outil —
|
||||
symptôme observé : "(pas de réponse textuelle)" dès le premier tour,
|
||||
aucune progression. Message désormais plus clair pour le créateur."""
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
cut_short = _FakeResponse([], "max_tokens") # aucun bloc texte, coupé en cours de réflexion
|
||||
fake_client = _FakeClient([cut_short])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
reply = ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Fais quelque chose de complexe")
|
||||
assert "interrompue" in reply
|
||||
assert fake_client.messages.call_count == 1 # stop_reason != "tool_use" -> sort dès le premier tour
|
||||
|
||||
|
||||
def test_run_chat_turn_uses_a_generous_max_tokens_not_the_old_lowballed_value(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="ok")], "end_turn")])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
assert fake_client.messages.last_kwargs["max_tokens"] >= 16000
|
||||
|
||||
|
||||
def test_run_chat_turn_raises_when_anthropic_is_not_configured(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
try:
|
||||
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
assert False, "devrait lever AnthropicNotConfiguredError"
|
||||
except ai.AnthropicNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------- État de scène ré-injecté à chaque tour (bug corrigé : Ruby
|
||||
# dupliquait des objets faute de voir ce qui existait déjà) ----------
|
||||
|
||||
def test_describe_scene_state_reports_dimensions_and_no_objects(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert "0 à 960" in description and "0 à 540" in description
|
||||
assert "Aucun objet" in description
|
||||
|
||||
|
||||
def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
player_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
screens.set_scene_object_role(slug, player_id, "joueur")
|
||||
pnj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
screens.set_scene_object_collision_rules(slug, pnj_id, screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert f"id={player_id}" in description
|
||||
assert "rôle=joueur" in description
|
||||
assert f"id={pnj_id}" in description
|
||||
assert "rôle=pnj" in description
|
||||
assert "[déclencheur déjà configuré]" in description
|
||||
|
||||
|
||||
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
fake_client = _FakeClient([_FakeResponse([_FakeBlock("text", text="ok")], "end_turn")])
|
||||
monkeypatch.setattr("ai.chat.get_client", lambda: fake_client)
|
||||
|
||||
ai.run_chat_turn(slug, screen_id, conversation_id, 1, "Salut")
|
||||
system_prompt = fake_client.messages.last_kwargs["system"]
|
||||
assert "0 à 960" in system_prompt and "0 à 540" in system_prompt
|
||||
assert "1 objet(s)" in system_prompt
|
||||
@@ -0,0 +1,19 @@
|
||||
"""ai/client.py — voir plan Phase 2, §2 : une clé absente est une
|
||||
configuration incomplète (AnthropicNotConfiguredError), jamais un crash
|
||||
brut ni une vraie clé nécessaire pour faire tourner ce test (voir
|
||||
tests/conftest.py, qui retire ANTHROPIC_API_KEY avant chaque test)."""
|
||||
import ai
|
||||
|
||||
|
||||
def test_get_client_raises_when_the_api_key_is_missing():
|
||||
try:
|
||||
ai.get_client()
|
||||
assert False, "devrait lever AnthropicNotConfiguredError"
|
||||
except ai.AnthropicNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
def test_get_client_returns_a_client_when_the_api_key_is_set(monkeypatch):
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-key-for-tests")
|
||||
client = ai.get_client()
|
||||
assert client is not None
|
||||
@@ -0,0 +1,74 @@
|
||||
"""ai/scenario_client.py — voir plan Phase 2, §3 : jamais un vrai appel
|
||||
réseau ici (requests.post/get monkeypatchés), et une configuration
|
||||
incomplète (clé/secret/modèle absents) doit échouer proprement AVANT
|
||||
toute tentative réseau."""
|
||||
import ai
|
||||
from ai.scenario_client import generate_image_url
|
||||
|
||||
|
||||
def test_generate_image_url_raises_when_not_configured():
|
||||
try:
|
||||
generate_image_url("un bureau moderne")
|
||||
assert False, "devrait lever ScenarioNotConfiguredError"
|
||||
except ai.ScenarioNotConfiguredError:
|
||||
pass
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, json_data):
|
||||
self._json = json_data
|
||||
|
||||
def raise_for_status(self):
|
||||
pass
|
||||
|
||||
def json(self):
|
||||
return self._json
|
||||
|
||||
|
||||
def test_generate_image_url_polls_until_success_then_returns_the_asset_url(monkeypatch):
|
||||
monkeypatch.setenv("SCENARIO_API_KEY", "fake-key")
|
||||
monkeypatch.setenv("SCENARIO_API_SECRET", "fake-secret")
|
||||
monkeypatch.setenv("SCENARIO_MODEL_ID", "fake-model")
|
||||
|
||||
calls = {"jobs": 0}
|
||||
|
||||
def fake_post(url, auth=None, json=None, timeout=None):
|
||||
assert url.endswith("/generate/txt2img")
|
||||
assert json["prompt"] == "un bureau moderne"
|
||||
assert json["modelId"] == "fake-model"
|
||||
return _FakeResponse({"job": {"jobId": "job_1"}})
|
||||
|
||||
def fake_get(url, auth=None, timeout=None):
|
||||
if "/jobs/" in url:
|
||||
calls["jobs"] += 1
|
||||
# Simule un job encore en cours au premier appel, réussi ensuite.
|
||||
status = "running" if calls["jobs"] == 1 else "success"
|
||||
body = {"job": {"status": status}}
|
||||
if status == "success":
|
||||
body["job"]["metadata"] = {"assetIds": ["asset_1"]}
|
||||
return _FakeResponse(body)
|
||||
assert url.endswith("/assets/asset_1")
|
||||
return _FakeResponse({"asset": {"url": "https://scenario.example/final.png"}})
|
||||
|
||||
monkeypatch.setattr("ai.scenario_client.requests.post", fake_post)
|
||||
monkeypatch.setattr("ai.scenario_client.requests.get", fake_get)
|
||||
monkeypatch.setattr("ai.scenario_client.time.sleep", lambda s: None)
|
||||
|
||||
url = generate_image_url("un bureau moderne")
|
||||
assert url == "https://scenario.example/final.png"
|
||||
assert calls["jobs"] == 2
|
||||
|
||||
|
||||
def test_generate_image_url_raises_on_job_failure(monkeypatch):
|
||||
monkeypatch.setenv("SCENARIO_API_KEY", "fake-key")
|
||||
monkeypatch.setenv("SCENARIO_API_SECRET", "fake-secret")
|
||||
monkeypatch.setenv("SCENARIO_MODEL_ID", "fake-model")
|
||||
|
||||
monkeypatch.setattr("ai.scenario_client.requests.post", lambda *a, **k: _FakeResponse({"job": {"jobId": "job_1"}}))
|
||||
monkeypatch.setattr("ai.scenario_client.requests.get", lambda *a, **k: _FakeResponse({"job": {"status": "failure"}}))
|
||||
|
||||
try:
|
||||
generate_image_url("un bureau")
|
||||
assert False, "devrait lever ScenarioGenerationError"
|
||||
except ai.ScenarioGenerationError:
|
||||
pass
|
||||
@@ -0,0 +1,315 @@
|
||||
"""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)."""
|
||||
import ai
|
||||
import auth
|
||||
import db
|
||||
import screens
|
||||
from ai.tools import TOOLS, dispatch_tool
|
||||
|
||||
|
||||
def _tool(name):
|
||||
return next(t for t in TOOLS if t["name"] == name)
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_ai_tools"):
|
||||
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, tmp_game_slug_cleanup):
|
||||
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 = 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, tmp_game_slug_cleanup):
|
||||
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 = 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, tmp_game_slug_cleanup):
|
||||
"""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"})
|
||||
assert False, "devrait lever ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_raises_for_an_unknown_forge_character(client, tmp_game_slug_cleanup):
|
||||
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"})
|
||||
assert False, "devrait lever ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
def test_dispatch_add_scene_object_forbids_admin_only_background_for_a_non_admin(client, user_client, tmp_game_slug_cleanup):
|
||||
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 = 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, user_client, tmp_game_slug_cleanup):
|
||||
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 = 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():
|
||||
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():
|
||||
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, tmp_game_slug_cleanup):
|
||||
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 = 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, tmp_game_slug_cleanup):
|
||||
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 = screens.get_scene_object(slug, object_id)
|
||||
assert obj["name"] == "Aka"
|
||||
|
||||
|
||||
def test_dispatch_set_object_geometry_clamps_a_position_outside_the_camera(client, tmp_game_slug_cleanup):
|
||||
"""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 = 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, tmp_game_slug_cleanup):
|
||||
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 = 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, tmp_game_slug_cleanup):
|
||||
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 = screens.get_scene_object(slug, object_id)
|
||||
assert (obj["width"], obj["height"]) == (2000, 1000)
|
||||
|
||||
|
||||
def test_dispatch_set_object_role_updates_the_role(client, tmp_game_slug_cleanup):
|
||||
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 = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_personnage_role(obj) == "ennemie"
|
||||
|
||||
|
||||
def test_dispatch_set_object_collision_updates_the_settings(client, tmp_game_slug_cleanup):
|
||||
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 = 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_create_global_variable_calls_db_directly(client, tmp_game_slug_cleanup):
|
||||
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, tmp_game_slug_cleanup):
|
||||
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 = 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, tmp_game_slug_cleanup):
|
||||
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 = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score_ia"
|
||||
|
||||
|
||||
def test_dispatch_tool_raises_for_an_unknown_tool_name(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
try:
|
||||
dispatch_tool(slug, screen_id, 1, "does_not_exist", {})
|
||||
assert False, "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):
|
||||
pass
|
||||
|
||||
|
||||
def test_dispatch_add_generated_image_saves_to_my_assets_and_poses_it(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
"""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 = 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)
|
||||
|
||||
|
||||
def test_dispatch_add_generated_image_surfaces_scenario_errors_as_a_tool_error(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
"""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):
|
||||
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"})
|
||||
assert False, "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():
|
||||
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_condition_operator_enum_matches_the_source_constant():
|
||||
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():
|
||||
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():
|
||||
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():
|
||||
"""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():
|
||||
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():
|
||||
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():
|
||||
schema = _tool("set_object_collision")["input_schema"]["properties"]["shape"]
|
||||
assert schema["enum"] == list(screens.COLLISION_SHAPES)
|
||||
@@ -372,3 +372,21 @@ def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(c
|
||||
html = client.get(f"/game/{slug}/play").data.decode()
|
||||
assert 'src="/static/js/play/offline/compute-operation.js"' in html
|
||||
assert 'src="/static/js/play/offline/apply-actions.js"' in html
|
||||
|
||||
|
||||
def test_add_scene_object_with_image_url_sets_src_without_background_library(client, tmp_game_slug_cleanup):
|
||||
""""Mes assets"/IA (voir plan Phase 1, §1) : une image ARBITRAIRE,
|
||||
jamais une clé de BACKGROUND_LIBRARY."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png")
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert obj["attributes"]["src"] == "/assets/1/abc.png"
|
||||
|
||||
|
||||
def test_add_scene_object_image_url_takes_priority_over_background_slug(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(
|
||||
slug, screen_id, kind="fond", background_slug="ne-existe-pas", image_url="/assets/1/abc.png",
|
||||
)
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert obj["attributes"]["src"] == "/assets/1/abc.png"
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
""".env local (voir plan Phase 2, §1, core/flask_app.py::load_dotenv())
|
||||
— propriété de sécurité dont dépend l'isolation des tests
|
||||
(tests/conftest.py pose FORGE_USERS_DB_PATH etc. AVANT cet import) :
|
||||
une variable déjà présente dans os.environ ne doit JAMAIS être écrasée
|
||||
par un .env local, même sur un poste où un vrai .env existe avec de
|
||||
vraies clés IA."""
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
import core.flask_app
|
||||
|
||||
|
||||
def test_load_dotenv_never_overrides_an_already_set_variable(monkeypatch):
|
||||
monkeypatch.setenv("FORGE_TEST_DOTENV_VAR", "valeur-deja-posee")
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".env", delete=False) as f:
|
||||
f.write("FORGE_TEST_DOTENV_VAR=valeur-du-fichier-env\n")
|
||||
env_path = f.name
|
||||
try:
|
||||
load_dotenv(env_path)
|
||||
assert os.environ["FORGE_TEST_DOTENV_VAR"] == "valeur-deja-posee"
|
||||
finally:
|
||||
os.remove(env_path)
|
||||
|
||||
|
||||
def test_core_flask_app_does_not_pass_override_true_to_load_dotenv():
|
||||
"""Garde-fou direct sur le code lui-même : si quelqu'un ajoute un
|
||||
jour `override=True`, un .env local avec une VRAIE clé écraserait
|
||||
silencieusement l'isolation des tests (voir ci-dessus)."""
|
||||
source = inspect.getsource(core.flask_app)
|
||||
assert "load_dotenv()" in source
|
||||
assert "override=True" not in source
|
||||
@@ -72,3 +72,50 @@ def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, g
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
|
||||
assert "score_total" in html
|
||||
assert 'data-tab="variables"' in html
|
||||
|
||||
|
||||
# ---------- Réponse AJAX (voir static/js/scenes/global-variables.js) —
|
||||
# demande explicite : plus de rechargement de page pour créer/modifier/
|
||||
# supprimer une variable. ----------
|
||||
|
||||
def test_create_global_var_ajax_returns_the_row_html_without_redirecting(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/new",
|
||||
data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["ok"] is True
|
||||
assert "score_total" in body["row_html"]
|
||||
assert 'data-var-row="' in body["row_html"]
|
||||
|
||||
|
||||
def test_global_var_edit_ajax_returns_ok_without_redirecting(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "0", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/{var_id}/edit",
|
||||
data={"type": "texte", "value": "beaucoup"},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True}
|
||||
assert db.list_global_variables(game)[0]["value"] == "beaucoup"
|
||||
|
||||
|
||||
def test_global_var_delete_ajax_returns_ok_without_redirecting(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
|
||||
var_id = db.list_global_variables(game)[0]["id"]
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/variables/{var_id}/delete",
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"ok": True}
|
||||
assert db.list_global_variables(game) == []
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Onglet "Générer avec l'IA" — plusieurs CONVERSATIONS par écran (CRUD,
|
||||
comme claude.ai, voir plan) : routes/ia/ia_chat.py + screens/ia/.
|
||||
ANTHROPIC_API_KEY est toujours absente en test (voir tests/conftest.py) :
|
||||
ces tests exercent donc le chemin de DÉGRADATION GRACIEUSE (message
|
||||
d'erreur clair, jamais une 500) pour l'envoi de message — pas un appel
|
||||
réel, quel que soit le .env local du poste."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_ia_chat"):
|
||||
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_create_and_list_conversations(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations")
|
||||
assert resp.status_code == 200
|
||||
conversation = resp.get_json()
|
||||
assert conversation["title"] == "Nouvelle conversation"
|
||||
|
||||
resp2 = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations")
|
||||
assert resp2.status_code == 200
|
||||
ids = [c["id"] for c in resp2.get_json()]
|
||||
assert conversation["id"] in ids
|
||||
|
||||
|
||||
def test_conversations_are_isolated_per_screen(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id_1 = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screen_id_2 = screens.create_screen(slug, "Scène 2", kind="jeu_2d")
|
||||
client.post(f"/game/{slug}/screens/{screen_id_1}/ia-conversations")
|
||||
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id_2}/ia-conversations")
|
||||
assert resp.get_json() == []
|
||||
|
||||
|
||||
def test_delete_conversation_removes_it_from_the_list(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
resp = client.delete(f"/game/{slug}/ia-conversations/{conversation['id']}")
|
||||
assert resp.status_code == 200
|
||||
|
||||
remaining = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
assert all(c["id"] != conversation["id"] for c in remaining)
|
||||
|
||||
|
||||
def test_delete_unknown_conversation_404s(client, game):
|
||||
resp = client.delete(f"/game/{game}/ia-conversations/999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_send_message_persists_it_and_a_graceful_error_reply(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "Génère un dialogue"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["user_message"]["content"] == "Génère un dialogue"
|
||||
assert body["user_message"]["role"] == "user"
|
||||
assert body["assistant_message"]["role"] == "assistant"
|
||||
assert "Configuration IA incomplète" in body["assistant_message"]["content"]
|
||||
|
||||
|
||||
def test_send_message_rejects_an_empty_message(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
resp = client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": " "})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_send_message_404s_for_an_unknown_conversation(client, game):
|
||||
resp = client.post(f"/game/{game}/ia-conversations/999999/messages", json={"message": "salut"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_messages_history_is_persisted_and_ordered(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "premier"})
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "second"})
|
||||
|
||||
resp = client.get(f"/game/{slug}/ia-conversations/{conversation['id']}/messages")
|
||||
assert resp.status_code == 200
|
||||
messages = resp.get_json()
|
||||
user_contents = [m["content"] for m in messages if m["role"] == "user"]
|
||||
assert user_contents == ["premier", "second"]
|
||||
|
||||
|
||||
def test_messages_are_isolated_per_conversation(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation_1 = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
conversation_2 = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation_1['id']}/messages", json={"message": "pour la 1ère"})
|
||||
|
||||
resp = client.get(f"/game/{slug}/ia-conversations/{conversation_2['id']}/messages")
|
||||
assert resp.get_json() == []
|
||||
|
||||
|
||||
def test_first_user_message_renames_the_conversation(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "Crée un fond de bureau"})
|
||||
|
||||
conversations = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
updated = next(c for c in conversations if c["id"] == conversation["id"])
|
||||
assert updated["title"] == "Crée un fond de bureau"
|
||||
|
||||
# Un deuxième message ne renomme plus (titre déjà dérivé du premier).
|
||||
client.post(f"/game/{slug}/ia-conversations/{conversation['id']}/messages", json={"message": "et une variable"})
|
||||
conversations2 = client.get(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
|
||||
updated2 = next(c for c in conversations2 if c["id"] == conversation["id"])
|
||||
assert updated2["title"] == "Crée un fond de bureau"
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Poser un objet de scène depuis "Mes assets" (voir plan Phase 1, §2) —
|
||||
routes/scenes/scene_object_add.py::asset_id, isolation stricte par
|
||||
propriétaire comme le reste de "Mes assets" (tests/test_user_assets.py)."""
|
||||
import re
|
||||
|
||||
import auth
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_object_asset"):
|
||||
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_scene_object_add_with_asset_id_poses_the_owners_image(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "abc123.png", original_name="photo.png")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data={"kind": "decor", "asset_id": str(asset_id)},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
obj = screens.get_scene_object(slug, body["id"])
|
||||
assert obj["attributes"]["src"] == f"/assets/{user_id}/abc123.png"
|
||||
|
||||
|
||||
def test_scene_object_add_with_asset_id_403s_for_another_accounts_asset(client, user_client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(other_user_id, "not_mine.png")
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
|
||||
data={"kind": "decor", "asset_id": str(asset_id)},
|
||||
headers={"X-Requested-With": "fetch"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
@@ -0,0 +1,80 @@
|
||||
""""Mes assets" (voir plan Phase 1, §2) — bibliothèque d'images PAR
|
||||
COMPTE, distincte du dossier d'un jeu (routes/uploads/upload_file.py) :
|
||||
réutilisable d'un projet à l'autre du MÊME compte, jamais visible par un
|
||||
autre."""
|
||||
import io
|
||||
|
||||
import auth
|
||||
|
||||
|
||||
def test_create_and_list_user_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "abc123.png", original_name="photo.png", source="upload")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["id"] == asset_id and a["filename"] == "abc123.png" for a in assets)
|
||||
|
||||
|
||||
def test_list_user_assets_is_isolated_per_account(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
auth.create_user_asset(admin_id, "admin_only.png")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert all(a["filename"] != "admin_only.png" for a in assets)
|
||||
|
||||
|
||||
def test_get_user_asset_returns_none_for_unknown_id():
|
||||
assert auth.get_user_asset(999999) is None
|
||||
|
||||
|
||||
def test_delete_user_asset_refuses_when_not_the_owner(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(admin_id, "admin_only.png")
|
||||
assert auth.delete_user_asset(asset_id, user_id) is False
|
||||
assert auth.get_user_asset(asset_id) is not None
|
||||
|
||||
|
||||
def test_upload_user_asset_route_stores_the_file_and_returns_a_url(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["id"]
|
||||
assert "/assets/" in body["url"]
|
||||
|
||||
dl = client.get(body["url"])
|
||||
assert dl.status_code == 200
|
||||
assert dl.data == b"fake-image-bytes"
|
||||
|
||||
|
||||
def test_upload_user_asset_route_requires_a_file(client):
|
||||
resp = client.post("/assets/upload", data={}, content_type="multipart/form-data")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_serve_user_asset_route_403s_for_another_account(client, user_client):
|
||||
# Un compte "user" fraîchement inscrit (sans projet) est redirigé vers
|
||||
# l'onboarding pour toute route hors _REACHABLE_WITHOUT_PROJECT (voir
|
||||
# core/auth_guard.py) — lui assigner un projet ici isole ce test sur
|
||||
# le SEUL comportement qu'on veut vérifier : l'isolation par
|
||||
# propriétaire de "Mes assets", pas le parcours d'onboarding.
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
auth.set_project_slug(other_user_id, "pytest_dummy_project")
|
||||
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
url = resp.get_json()["url"]
|
||||
resp2 = user_client.get(url)
|
||||
assert resp2.status_code == 403
|
||||
Reference in New Issue
Block a user