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>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+65 -23
View File
@@ -7,10 +7,15 @@ simulé. Chaque jeu de test est créé avec un nom préfixé "pytest_" et son
dossier est supprimé automatiquement à la fin du test (fixture `game`),
pour ne jamais laisser de données de test dans projects/.
"""
import os
import shutil
import sys
import tempfile
from collections.abc import Callable, Generator
from typing import Any, TypeVar
from flask.testing import FlaskClient
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -29,17 +34,20 @@ os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
# vrai user_assets/ du poste pendant les tests.
os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets")
import pytest
import pytest # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
from core.flask_app import app as flask_app
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (colname, truncatesentences)
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
from core import auth_guard # noqa: F401 - enregistre la garde de connexion (après les routes)
from core import csrf # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
from core import csrf_guard # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
from core import recovery_codes_flash # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
import auth
import db
import auth # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
import db # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
import routes # noqa: F401, E402 - enregistre toutes les routes sur flask_app
from core import ( # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
auth_guard, # noqa: F401 - enregistre la garde de connexion (après les routes)
csrf, # noqa: F401 - enregistre csrf_token() comme variable globale Jinja
csrf_guard, # noqa: F401 - enregistre la vérification du jeton CSRF (désactivée en TESTING, voir test_csrf.py)
db_teardown_guard, # noqa: F401 - enregistre la fermeture des connexions SQLite fuitées
jinja_filters, # noqa: F401 - enregistre les filtres Jinja (colname, truncatesentences)
recovery_codes_flash, # noqa: F401 - enregistre pop_recovery_codes() comme variable globale Jinja
)
from core.flask_app import app as flask_app # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
# 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 —
@@ -61,15 +69,26 @@ for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRE
_TEST_ADMIN_ID = auth.create_user("admin@pytest.local", "Test1234!", "Test", "Admin")
auth.confirm_totp(_TEST_ADMIN_ID)
_T = TypeVar("_T")
def not_none(value: _T | None) -> _T:
"""Pour les endroits où Mypy voit un `X | None` (ex. db.get_xxx) mais où
le test sait, par construction, que la valeur existe forcément (vient
d'être créée/insérée juste au-dessus) — lève AssertionError (message
clair) plutôt qu'un TypeError cru si jamais cette hypothèse était fausse."""
assert value is not None
return value
@pytest.fixture
def tmp_game_slug_cleanup():
def tmp_game_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
"""Pour les tests qui appellent db.create_game(...) directement (sans
passer par une route Flask) : enregistre un slug à nettoyer à la fin du
test, quel que soit son résultat."""
slugs = []
def register(slug):
def register(slug: str) -> str:
slugs.append(slug)
return slug
@@ -80,8 +99,26 @@ def tmp_game_slug_cleanup():
shutil.rmtree(game_dir)
@pytest.fixture(autouse=True)
def _cleanup_admin_assets() -> Generator[None, None, None]:
"""Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ
par toute la session de tests (voir plus haut) — un test qui crée un
"asset" dessus (upload, outil IA...) sans le nettoyer lui-même pollue
tous les tests suivants. Complète (ne remplace pas) les nettoyages
ponctuels déjà faits dans le corps de certains tests : ceux-ci restent
nécessaires quand le test vérifie un invariant sur la liste des assets
AVANT sa propre fin (ce filet ne rattrape qu'après coup). `_TEST_ADMIN_ID`
est fixe pour toute la session (un seul admin existe, voir plus haut) —
pas besoin de le résoudre dynamiquement par test."""
before = {asset["id"] for asset in auth.list_user_assets(_TEST_ADMIN_ID)}
yield
for asset in auth.list_user_assets(_TEST_ADMIN_ID):
if asset["id"] not in before:
auth.delete_user_asset(asset["id"], _TEST_ADMIN_ID)
@pytest.fixture
def client():
def client() -> Generator[FlaskClient, None, None]:
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
with c.session_transaction() as sess:
@@ -93,7 +130,7 @@ _TEST_USER_COUNTER = [0]
@pytest.fixture
def user_client():
def user_client() -> Generator[FlaskClient, None, None]:
"""Un compte "user" (jamais le premier de la session de tests, donc
jamais admin — voir auth/create_user.py), connecté mais SANS projet
assigné pour l'instant (voir la fixture `user_game` ci-dessous) —
@@ -112,7 +149,7 @@ def user_client():
@pytest.fixture
def user_game(user_client):
def user_game(user_client: FlaskClient) -> Generator[str, None, None]:
"""Un jeu appartenant réellement au compte `user_client` — le dossier
PROPRIÉTAIRE (voir db/games/project_slug.py) doit correspondre à
l'id de CE compte (core/auth_guard.py compare désormais les deux) :
@@ -131,7 +168,7 @@ def user_game(user_client):
@pytest.fixture
def game(client):
def game(client: FlaskClient) -> Generator[str, None, None]:
"""Crée un jeu de test frais et le supprime après le test, quel que
soit son résultat (succès ou échec)."""
resp = client.post("/games/new", data={"name": "pytest_test_game"}, follow_redirects=False)
@@ -147,7 +184,7 @@ _PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play")
_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit")
def _js_bundle(directory):
def _js_bundle(directory: str) -> str:
parts = []
for name in sorted(os.listdir(directory)):
path = os.path.join(directory, name)
@@ -157,7 +194,7 @@ def _js_bundle(directory):
return "\n".join(parts)
def play_js_bundle():
def play_js_bundle() -> str:
"""Concatène tout le JS du moteur jouable (voir le plan de
modularisation — static/js/play/, un fichier par responsabilité,
extrait de l'ancien <script> unique de templates/play.html). Plusieurs
@@ -168,16 +205,21 @@ def play_js_bundle():
return _js_bundle(_PLAY_JS_DIR)
def screen_edit_js_bundle():
def screen_edit_js_bundle() -> str:
"""Même principe que play_js_bundle(), pour l'éditeur (static/js/
screen_edit/, extrait de templates/screen_edit.html)."""
return _js_bundle(_SCREEN_EDIT_JS_DIR)
@pytest.fixture
def mail_object(client, game):
def mail_object(client: FlaskClient, game: str) -> dict[str, Any]:
"""Un objet "Mail" avec deux champs texte (sujet, expediteur)."""
definition_id = db.create_definition(game, "Mail", [
{"name": "sujet", "type": "texte"}, {"name": "expediteur", "type": "texte"},
])
definition_id = db.create_definition(
game,
"Mail",
[
{"name": "sujet", "type": "texte"},
{"name": "expediteur", "type": "texte"},
],
)
return {"slug": game, "definition_id": definition_id}
+8 -4
View File
@@ -1,18 +1,22 @@
"""Déclaration d'accessibilité (voir publish/accessibility_declaration.py,
routes/publish/accessibility_declaration.py) — un résumé de ce que le
moteur implémente, PAS une certification RGAA complète."""
from flask.testing import FlaskClient
from tests.test_auth import anon_client # noqa: F401
def test_accessibility_declaration_route_returns_a_text_attachment(client, game):
def test_accessibility_declaration_route_returns_a_text_attachment(client: FlaskClient, game: str) -> None:
resp = client.get(f"/game/{game}/accessibility-declaration")
assert resp.status_code == 200
assert "text/plain" in resp.headers["Content-Type"]
assert "attachment" in resp.headers["Content-Disposition"]
def test_accessibility_declaration_mentions_the_game_name(client, game):
def test_accessibility_declaration_mentions_the_game_name(client: FlaskClient, game: str) -> None:
import db
game_name = db.game_meta(game)["name"]
resp = client.get(f"/game/{game}/accessibility-declaration")
text = resp.data.decode("utf-8")
@@ -20,10 +24,10 @@ def test_accessibility_declaration_mentions_the_game_name(client, game):
assert game_name in text
def test_accessibility_declaration_route_is_isolated_like_other_game_routes(anon_client):
def test_accessibility_declaration_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
_register(anon_client, "a11ydeclarationisolation@example.com")
_confirm_2fa(anon_client)
+37 -17
View File
@@ -3,19 +3,24 @@ une ligne VIDE, dont l'id devient LAST_INSERTED_ROW_ID pour un nœud
"Modifier une donnée" enchaîné dans le MÊME flow — verrouille le
mécanisme complet (route -> DB -> répéteur) plutôt que juste la fonction
Python isolée."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -25,15 +30,20 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def _create_inventory_object(client, slug):
def _create_inventory_object(client: FlaskClient, slug: str) -> Any:
return db.create_definition(slug, "Inventaire", [{"name": "objet", "type": "texte"}])
def test_run_add_row_route_creates_an_empty_row(client, game):
def test_run_add_row_route_creates_an_empty_row(client: FlaskClient, game: str) -> None:
definition_id = _create_inventory_object(client, game)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="ajouter_ligne", target_definition_id=definition_id,
client,
game,
screen_id,
node_type="action",
action_type="ajouter_ligne",
target_definition_id=definition_id,
)
resp = client.post(f"/game/{game}/flow/nodes/{node_id}/run-add-row")
assert resp.status_code == 200
@@ -41,14 +51,14 @@ def test_run_add_row_route_creates_an_empty_row(client, game):
assert body["ok"] is True
assert isinstance(body["row_id"], int)
definition = db.get_definition(game, definition_id)
definition = not_none(db.get_definition(game, definition_id))
rows = db.list_rows(game, definition)
assert len(rows) == 1
assert rows[0]["id"] == body["row_id"]
assert rows[0]["objet"] is None
def test_run_add_row_route_rejects_wrong_action_type(client, game):
def test_run_add_row_route_rejects_wrong_action_type(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
node_id = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1")
resp = client.post(f"/game/{game}/flow/nodes/{node_id}/run-add-row")
@@ -56,7 +66,7 @@ def test_run_add_row_route_rejects_wrong_action_type(client, game):
assert resp.get_json()["ok"] is False
def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(client, game):
def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(client: FlaskClient, game: str) -> None:
"""Le scénario réel : "Ajouter une ligne" crée une ligne vide, puis un
"Modifier une donnée" ciblant LAST_INSERTED_ROW_ID (transmis par le
client via last_inserted_row_id, voir static/js/play/actions.js) la
@@ -64,12 +74,24 @@ def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(clie
definition_id = _create_inventory_object(client, game)
screen_id = _create_screen(client, game)
add_node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="ajouter_ligne", target_definition_id=definition_id,
client,
game,
screen_id,
node_type="action",
action_type="ajouter_ligne",
target_definition_id=definition_id,
)
set_node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="modifier_donnee",
target_definition_id=definition_id, target_row_id=screens.LAST_INSERTED_ROW_ID,
target_field="objet", data_operation="definir_texte", data_value="épée",
client,
game,
screen_id,
node_type="action",
action_type="modifier_donnee",
target_definition_id=definition_id,
target_row_id=screens.LAST_INSERTED_ROW_ID,
target_field="objet",
data_operation="definir_texte",
data_value="épée",
)
add_resp = client.post(f"/game/{game}/flow/nodes/{add_node_id}/run-add-row")
@@ -82,8 +104,6 @@ def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(clie
)
assert set_resp.get_json()["ok"] is True
definition = db.get_definition(game, definition_id)
row = db.get_row(game, definition, new_row_id)
definition = not_none(db.get_definition(game, definition_id))
row = not_none(db.get_row(game, definition, new_row_id))
assert row["objet"] == "épée"
+162 -35
View File
@@ -1,12 +1,22 @@
"""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."""
from collections.abc import Callable
from typing import Any
import pytest
from flask.testing import FlaskClient
import ai
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup, name="pytest_ai_chat"):
def _create_jeu2d_game_with_conversation(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_ai_chat"
) -> 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")
@@ -15,7 +25,9 @@ def _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup, name="py
class _FakeBlock:
def __init__(self, type, text=None, id=None, name=None, input=None):
def __init__(
self, type: str, text: str | None = None, id: str | None = None, name: str | None = None, input: Any = None
) -> None:
self.type = type
self.text = text
self.id = id
@@ -24,18 +36,18 @@ class _FakeBlock:
class _FakeResponse:
def __init__(self, content, stop_reason):
def __init__(self, content: Any, stop_reason: Any) -> None:
self.content = content
self.stop_reason = stop_reason
class _FakeMessages:
def __init__(self, responses):
def __init__(self, responses: Any) -> None:
self._responses = list(responses)
self.call_count = 0
self.last_kwargs = None
self.last_kwargs: dict[str, Any] | None = None
def create(self, **kwargs):
def create(self, **kwargs: Any) -> Any:
self.call_count += 1
self.last_kwargs = kwargs
if len(self._responses) > 1:
@@ -44,11 +56,13 @@ class _FakeMessages:
class _FakeClient:
def __init__(self, responses):
def __init__(self, responses: Any) -> None:
self.messages = _FakeMessages(responses)
def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(client, tmp_game_slug_cleanup, monkeypatch):
def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
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)
@@ -58,10 +72,19 @@ def test_run_chat_turn_returns_text_directly_when_no_tool_is_used(client, tmp_ga
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):
def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
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"})],
[
_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")
@@ -74,7 +97,9 @@ def test_run_chat_turn_dispatches_a_tool_call_then_returns_the_final_text(client
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):
def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calling_tools(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
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"})],
@@ -88,7 +113,9 @@ def test_run_chat_turn_stops_after_the_max_iterations_even_if_claude_keeps_calli
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):
def test_run_chat_turn_explains_when_cut_short_by_max_tokens(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
"""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,
@@ -103,20 +130,24 @@ def test_run_chat_turn_explains_when_cut_short_by_max_tokens(client, tmp_game_sl
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):
def test_run_chat_turn_uses_a_generous_max_tokens_not_the_old_lowballed_value(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
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
assert not_none(fake_client.messages.last_kwargs)["max_tokens"] >= 16000
def test_run_chat_turn_raises_when_anthropic_is_not_configured(client, tmp_game_slug_cleanup):
def test_run_chat_turn_raises_when_anthropic_is_not_configured(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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"
raise AssertionError("devrait lever AnthropicNotConfiguredError")
except ai.AnthropicNotConfiguredError:
pass
@@ -124,21 +155,32 @@ def test_run_chat_turn_raises_when_anthropic_is_not_configured(client, tmp_game_
# ---------- É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):
def test_describe_scene_state_reports_dimensions_and_no_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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):
def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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": []}}},
]))
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
@@ -148,41 +190,60 @@ def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(
assert "[déclencheur déjà configuré]" in description
def test_describe_scene_state_reports_no_screen_trigger_by_default(client, tmp_game_slug_cleanup):
def test_describe_scene_state_reports_no_screen_trigger_by_default(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
description = ai.chat._describe_scene_state(slug, screen_id)
assert "Aucun déclencheur d'écran" in description
def test_describe_scene_state_reports_an_existing_screen_trigger(client, tmp_game_slug_cleanup):
def test_describe_scene_state_reports_an_existing_screen_trigger(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
{"trigger": "affichage", "action": {"type": "dialogue"}},
]))
screens.set_screen_triggers(
slug,
screen_id,
screens.sanitize_screen_triggers(
[
{"trigger": "affichage", "action": {"type": "dialogue"}},
]
),
)
description = ai.chat._describe_scene_state(slug, screen_id)
assert "1 déclencheur(s) D'ÉCRAN" in description
def test_system_prompt_documents_the_new_triggers_and_actions():
def test_system_prompt_documents_the_new_triggers_and_actions() -> None:
"""Bug à éviter : ajouter un type de déclencheur/action côté moteur
(screens/rendering/collision_rules.py) sans jamais le documenter dans
le system prompt le rendrait invisible pour Ruby malgré l'outil qui
l'accepte techniquement."""
prompt = ai.chat._SYSTEM_PROMPT
for keyword in ("\"clic\"", "\"survol\"", "\"affichage\"", "surbrillance", "visibilite", "\"son\"", "\"video\"", "indication"):
for keyword in (
'"clic"',
'"survol"',
'"affichage"',
"surbrillance",
"visibilite",
'"son"',
'"video"',
"indication",
):
assert keyword in prompt, keyword
def test_system_prompt_asks_ruby_to_clarify_ambiguous_trigger_choice():
def test_system_prompt_asks_ruby_to_clarify_ambiguous_trigger_choice() -> None:
assert "pose la question" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action():
def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action() -> None:
assert "interagir" in ai.chat._SYSTEM_PROMPT
assert "APPUYER SUR UNE TOUCHE" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_documents_the_quiz_box_config_tool():
def test_system_prompt_documents_the_quiz_box_config_tool() -> None:
"""Demande explicite : "Ruby dois pouvoir aussi piloter ces réglages"
(plein écran/minuteur/modèle d'un quiz autonome) — pas seulement
l'éditeur manuel."""
@@ -192,11 +253,11 @@ def test_system_prompt_documents_the_quiz_box_config_tool():
assert keyword in prompt, keyword
def test_system_prompt_asks_ruby_to_clarify_the_timer_before_enabling_it():
def test_system_prompt_asks_ruby_to_clarify_the_timer_before_enabling_it() -> None:
assert "jamais imposé" in ai.chat._SYSTEM_PROMPT
def test_system_prompt_documents_the_two_quiz_template_categories():
def test_system_prompt_documents_the_two_quiz_template_categories() -> None:
"""Demande explicite : "Ruby doit également comprendre cette
distinction" (modèles "boîte de dialogue" hors plein écran vs modèles
"page de quiz" en plein écran, structures HTML totalement séparées)."""
@@ -205,13 +266,79 @@ def test_system_prompt_documents_the_two_quiz_template_categories():
assert "manga" in prompt
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
# ---------- Écran supprimé pendant qu'une conversation IA existe encore
# ----------
#
# Comportement RÉEL vérifié en creusant ce chantier de typage (voir
# ai/chat.py::ScreenDeletedError, screens/screens_repo/delete_screen.py) :
# _ia_conversations.screen_id porte déjà ON DELETE CASCADE vers _screens
# (voir screens/ia/ensure_ia_chat_schema.py) — supprimer un écran nettoie
# donc DÉJÀ ses conversations/messages automatiquement, sans code
# applicatif dédié. ScreenDeletedError (ai/chat.py) reste un garde-fou
# purement défensif pour get_screen() -> None (Optional), pas le
# traitement d'un cas normalement atteignable via l'appli.
def test_deleting_the_screen_cascades_to_remove_its_ia_conversations_and_messages(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
screens.add_ia_chat_message(slug, conversation_id, "user", "Salut")
screens.delete_screen(slug, screen_id)
conn = db.connect(slug)
remaining_conversations = conn.execute(
"SELECT COUNT(*) AS n FROM _ia_conversations WHERE screen_id = ?", (screen_id,)
).fetchone()["n"]
remaining_messages = conn.execute(
"SELECT COUNT(*) AS n FROM _ia_chat_messages WHERE conversation_id = ?", (conversation_id,)
).fetchone()["n"]
conn.close()
assert remaining_conversations == 0
assert remaining_messages == 0
def test_describe_scene_state_raises_a_clear_error_for_a_nonexistent_screen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""screens.get_screen() renvoie dict | None — jamais atteint via
l'appli normale (voir le commentaire de module ci-dessus), mais
_describe_scene_state doit rester correcte pour ce cas plutôt que de
planter avec un TypeError cru sur un accès à None."""
slug, _screen_id, _conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
try:
ai.chat._describe_scene_state(slug, 999999)
raise AssertionError("devrait lever ScreenDeletedError")
except ai.chat.ScreenDeletedError:
pass
def test_run_chat_turn_gives_a_clear_message_instead_of_crashing_for_a_nonexistent_screen(
client: FlaskClient,
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
# screen_id inexistant (jamais créé) plutôt que l'écran réel de la
# conversation : reproduit directement l'état que ScreenDeletedError
# doit couvrir, sans avoir à contourner la contrainte FK réelle.
# Aucun monkeypatch de get_client ici, exprès : si le garde-fou
# appelait le client Claude avant de vérifier l'écran, ce test
# échouerait avec AnthropicNotConfiguredError (aucune clé en
# environnement de test) plutôt qu'avec le bon message.
reply = ai.run_chat_turn(slug, 999999, conversation_id, 1, "Salut")
assert reply == "Cet écran a été supprimé — cette conversation n'est plus utilisable."
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], monkeypatch: pytest.MonkeyPatch
) -> None:
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"]
system_prompt = not_none(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
+6 -3
View File
@@ -2,18 +2,21 @@
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 pytest
import ai
def test_get_client_raises_when_the_api_key_is_missing():
def test_get_client_raises_when_the_api_key_is_missing() -> None:
try:
ai.get_client()
assert False, "devrait lever AnthropicNotConfiguredError"
raise AssertionError("devrait lever AnthropicNotConfiguredError")
except ai.AnthropicNotConfiguredError:
pass
def test_get_client_returns_a_client_when_the_api_key_is_set(monkeypatch):
def test_get_client_returns_a_client_when_the_api_key_is_set(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-fake-key-for-tests")
client = ai.get_client()
assert client is not None
+19 -12
View File
@@ -2,48 +2,53 @@
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."""
from typing import Any
import pytest
import ai
from ai.scenario_client import generate_image_url
def test_generate_image_url_raises_when_not_configured():
def test_generate_image_url_raises_when_not_configured() -> None:
try:
generate_image_url("un bureau moderne")
assert False, "devrait lever ScenarioNotConfiguredError"
raise AssertionError("devrait lever ScenarioNotConfiguredError")
except ai.ScenarioNotConfiguredError:
pass
class _FakeResponse:
def __init__(self, json_data):
def __init__(self, json_data: Any) -> None:
self._json = json_data
def raise_for_status(self):
def raise_for_status(self) -> None:
pass
def json(self):
def json(self) -> Any:
return self._json
def test_generate_image_url_polls_until_success_then_returns_the_asset_url(monkeypatch):
def test_generate_image_url_polls_until_success_then_returns_the_asset_url(monkeypatch: pytest.MonkeyPatch) -> None:
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):
def fake_post(url: str, auth: Any = None, json: Any = None, timeout: int | None = None) -> Any:
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):
def fake_get(url: str, auth: Any = None, timeout: int | None = None) -> Any:
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}}
body: dict[str, Any] = {"job": {"status": status}}
if status == "success":
body["job"]["metadata"] = {"assetIds": ["asset_1"]}
return _FakeResponse(body)
@@ -59,16 +64,18 @@ def test_generate_image_url_polls_until_success_then_returns_the_asset_url(monke
assert calls["jobs"] == 2
def test_generate_image_url_raises_on_job_failure(monkeypatch):
def test_generate_image_url_raises_on_job_failure(monkeypatch: pytest.MonkeyPatch) -> None:
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"}}))
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"
raise AssertionError("devrait lever ScenarioGenerationError")
except ai.ScenarioGenerationError:
pass
+319 -123
View File
@@ -3,43 +3,59 @@ 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):
def _tool(name: str) -> Any:
return next(t for t in TOOLS if t["name"] == name)
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_ai_tools"):
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, result["object_id"])
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, result["object_id"])
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, tmp_game_slug_cleanup):
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
@@ -50,73 +66,94 @@ def test_dispatch_add_scene_object_raises_for_an_unknown_background_slug(client,
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"
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, tmp_game_slug_cleanup):
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"})
assert False, "devrait lever ValueError"
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, user_client, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, result["object_id"])
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, user_client, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, result["object_id"])
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():
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():
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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
@@ -125,200 +162,342 @@ def test_dispatch_set_object_geometry_clamps_a_position_outside_the_camera(clien
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})
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)
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, tmp_game_slug_cleanup):
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})
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)
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, tmp_game_slug_cleanup):
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})
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)
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, object_id)
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",
"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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, 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, tmp_game_slug_cleanup):
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"})
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):
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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"},
})
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)
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, tmp_game_slug_cleanup):
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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"}}]
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, tmp_game_slug_cleanup):
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"},
})
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, tmp_game_slug_cleanup):
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": []}}},
]))
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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"})
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 = screens.get_scene_object(slug, object_id)
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, tmp_game_slug_cleanup):
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": []}}},
]))
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, tmp_game_slug_cleanup):
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": []}},
},
}]))
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, tmp_game_slug_cleanup):
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", {})
assert False, "devrait lever KeyError"
raise AssertionError("devrait lever KeyError")
except KeyError:
pass
@@ -327,13 +506,16 @@ 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):
def raise_for_status(self) -> None:
pass
def test_dispatch_add_generated_image_saves_to_my_assets_and_poses_it(client, tmp_game_slug_cleanup, monkeypatch):
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)."""
@@ -344,28 +526,33 @@ def test_dispatch_add_generated_image_saves_to_my_assets_and_poses_it(client, tm
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"])
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, tmp_game_slug_cleanup, monkeypatch):
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):
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"})
assert False, "devrait lever ScenarioNotConfiguredError"
raise AssertionError("devrait lever ScenarioNotConfiguredError")
except ai.ScenarioNotConfiguredError:
pass
@@ -377,86 +564,95 @@ def test_dispatch_add_generated_image_surfaces_scenario_errors_as_a_tool_error(c
# 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():
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():
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():
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():
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():
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():
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():
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():
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():
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():
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():
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():
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():
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():
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)
+37 -12
View File
@@ -3,12 +3,19 @@ screens/labels/animal_sprite_library.py) — réservée au rôle admin
(licence CraftPix, voir core/sprite_gate.py et le plan "Bibliothèque de
sprites animaux CraftPix"). Un compte "user" ne doit ni la voir dans la
galerie de l'éditeur, ni pouvoir l'assigner par un POST direct."""
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def test_admin_sprite_library_loaded_from_manifest():
def test_admin_sprite_library_loaded_from_manifest() -> None:
assert len(screens.ADMIN_SPRITE_LIBRARY) > 0
bunny = screens.ADMIN_SPRITE_LIBRARY["animal-bunny-01"]
assert bunny["family"] == "bunny"
@@ -16,13 +23,13 @@ def test_admin_sprite_library_loaded_from_manifest():
assert len(bunny["animations"]["idle"]) == 20
def test_sprite_library_is_public_plus_admin_merged():
def test_sprite_library_is_public_plus_admin_merged() -> None:
assert set(screens.SPRITE_LIBRARY) == set(screens.PUBLIC_SPRITE_LIBRARY) | set(screens.ADMIN_SPRITE_LIBRARY)
assert screens.ADMIN_ONLY_CHARACTER_SLUGS == set(screens.ADMIN_SPRITE_LIBRARY)
assert set(screens.ADMIN_SPRITE_LIBRARY) == screens.ADMIN_ONLY_CHARACTER_SLUGS
assert "male-adventurer" not in screens.ADMIN_ONLY_CHARACTER_SLUGS
def test_sprite_gallery_families_shows_one_tile_per_animal():
def test_sprite_gallery_families_shows_one_tile_per_animal() -> None:
families = screens.sprite_gallery_families(screens.SPRITE_LIBRARY)
slugs = [slug for slug, _ in families]
assert len(slugs) == len(screens.PUBLIC_SPRITE_LIBRARY) + 14 # 6 Kenney + 14 familles animales
@@ -30,7 +37,7 @@ def test_sprite_gallery_families_shows_one_tile_per_animal():
assert "animal-bunny-02" not in slugs
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
def _create_jeu2d_game_as(post_client: FlaskClient, name: str, slug_owner_client: FlaskClient | None = None) -> Any:
# Fusion des moteurs (voir screens/screens_repo/ensure_schema.py) : un
# jeu n'a plus de type propre — seul l'écran créé ensuite par chaque
# test (screens.create_screen(..., kind="jeu_2d")) est une scène.
@@ -39,20 +46,26 @@ def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
return resp.headers["Location"].rstrip("/").split("/")[-1]
def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_client, tmp_game_slug_cleanup):
def test_scene_editor_hides_animal_characters_from_a_user_account(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_user"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
def test_scene_object_add_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
def test_scene_object_add_rejects_animal_character_for_a_user_account(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
resp = user_client.post(
@@ -62,7 +75,9 @@ def test_scene_object_add_rejects_animal_character_for_a_user_account(client, us
assert resp.status_code == 403
def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup):
def test_scene_object_add_allows_animal_character_for_admin(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin"))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
resp = client.post(
@@ -72,14 +87,17 @@ def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slu
)
assert resp.status_code == 302
obj_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_data(obj)["forge_character"] == "animal-bunny-01"
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(
client: FlaskClient, user_client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_swap_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
obj_id = screens.add_scene_object(slug, screen_id)
@@ -91,7 +109,9 @@ def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_accoun
assert resp.status_code == 403
def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp_game_slug_cleanup):
def test_list_used_forge_characters_scans_elements_and_scene_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_used_characters"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
@@ -100,16 +120,20 @@ def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp
assert used == {"animal-bunny-01"}
def test_build_scorm_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
def test_build_scorm_package_only_copies_used_animal_character_folders(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], tmp_path: Path
) -> None:
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_scorm_animals"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
with client.application.test_request_context():
from publish import build_scorm_package
zip_path, _name = build_scorm_package.build_scorm_zip(slug)
try:
import zipfile
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
animal_paths = {n for n in names if "static/characters/animals/" in n and not n.endswith("manifest.json")}
@@ -121,4 +145,5 @@ def test_build_scorm_package_only_copies_used_animal_character_folders(client, t
assert any("static/characters/male-adventurer/" in n for n in names)
finally:
import os
os.remove(zip_path)
+38 -19
View File
@@ -1,31 +1,44 @@
"""Tests de la timeline d'animation : clips Animate.css ou personnalisés
(images-clés), posés sur la piste d'un élément, avec un instant de départ
et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran)."""
import json
import re
from typing import Any
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import screens as screens_module
from conftest import play_js_bundle
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_element(client, slug, screen_id, widget=None):
def _add_element(client: FlaskClient, slug: str, screen_id: int, widget: str | None = None) -> Any:
return screens_module.add_scene_object(slug, screen_id)
def test_animate_css_clip_persists(client, game):
def test_animate_css_clip_persists(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
resp = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({
"element_id": el_id, "kind": "animate_css", "animate_name": "bounce",
"start_time": "1.5", "duration": "2", "delay": "0", "easing": "ease", "iteration_count": "1",
}),
data=json.dumps(
{
"element_id": el_id,
"kind": "animate_css",
"animate_name": "bounce",
"start_time": "1.5",
"duration": "2",
"delay": "0",
"easing": "ease",
"iteration_count": "1",
}
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -37,16 +50,21 @@ def test_animate_css_clip_persists(client, game):
assert clip["element_id"] == el_id
def test_custom_keyframes_clip_persists(client, game):
def test_custom_keyframes_clip_persists(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
keyframes = [{"percent": 0, "props": {"opacity": "0"}}, {"percent": 100, "props": {"opacity": "1"}}]
resp = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({
"element_id": el_id, "kind": "custom", "custom_keyframes": keyframes,
"start_time": "0", "duration": "3",
}),
data=json.dumps(
{
"element_id": el_id,
"kind": "custom",
"custom_keyframes": keyframes,
"start_time": "0",
"duration": "3",
}
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -55,7 +73,7 @@ def test_custom_keyframes_clip_persists(client, game):
assert json.loads(clip["custom_keyframes"]) == keyframes
def test_clip_update_changes_timing(client, game):
def test_clip_update_changes_timing(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
clip = client.post(
@@ -74,7 +92,7 @@ def test_clip_update_changes_timing(client, game):
assert updated["duration"] == 0.5
def test_clip_delete_removes_it(client, game):
def test_clip_delete_removes_it(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
clip = client.post(
@@ -85,10 +103,11 @@ def test_clip_delete_removes_it(client, game):
resp = client.post(f"/game/{game}/animations/clips/{clip['id']}/delete")
assert resp.status_code == 200
import screens
assert screens.get_animation_clip(game, clip["id"]) is None
def test_scene_editor_loads_animate_css(client, game):
def test_scene_editor_loads_animate_css(client: FlaskClient, game: str) -> None:
"""L'onglet dédié "Timeline d'animation" (animTabPanel) n'existe que
dans l'éditeur document, retiré — l'éditeur de scène 2D n'a pas encore
d'UI dédiée pour poser un clip (voir templates/scene_edit.html), mais
@@ -100,7 +119,7 @@ def test_scene_editor_loads_animate_css(client, game):
assert "animate.min.css" in html
def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
def test_play_page_exposes_animation_runtime_and_animate_css(client: FlaskClient, game: str) -> None:
resp = client.get(f"/game/{game}/play")
html = resp.data.decode()
assert "animate.min.css" in html
@@ -109,7 +128,7 @@ def test_play_page_exposes_animation_runtime_and_animate_css(client, game):
assert "injectCustomKeyframes" in js
def test_play_page_payload_includes_clip(client, game):
def test_play_page_payload_includes_clip(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id, "titre")
client.post(
+95 -66
View File
@@ -8,31 +8,49 @@ conftest.py en a déjà créé un pour authentifier tous les AUTRES tests du
moteur — ces tests-ci utilisent volontairement un client Flask NU (pas la
fixture `client`, déjà connectée) pour exercer le parcours anonyme
(inscription/connexion) de bout en bout."""
import re
from collections.abc import Generator
from typing import Any
import pyotp
import pytest
from flask.testing import FlaskClient
import auth
import db
from core.flask_app import app as flask_app
from tests.conftest import not_none
@pytest.fixture
def anon_client():
def anon_client() -> Generator[FlaskClient, None, None]:
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
yield c
def _register(client, email, prenom="Alice", nom="Test", password="Sup3r$ecret!"):
return client.post("/register", data={
"prenom": prenom, "nom": nom, "email": email,
"password": password, "password_confirm": password,
}, follow_redirects=False)
def _register(
client: FlaskClient,
email: str,
prenom: str = "Alice",
nom: str = "Test",
password: str = "Sup3r$ecret!", # nosec B107 # noqa: S107 - mot de passe de fixture de test, jamais un vrai compte
) -> Any:
return client.post(
"/register",
data={
"prenom": prenom,
"nom": nom,
"email": email,
"password": password,
"password_confirm": password,
},
follow_redirects=False,
)
def _confirm_2fa(client):
def _confirm_2fa(client: FlaskClient) -> Any:
"""Récupère la clé TOTP affichée sur la page de confirmation et
soumet le code du moment — comme un humain qui vient de scanner le
QR code avec son application. Un compte "user" n'a plus de projet
@@ -41,75 +59,85 @@ def _confirm_2fa(client):
voir _complete_onboarding ci-dessous pour les tests qui ont besoin
d'un vrai projet ensuite."""
html = client.get("/register/2fa").get_data(as_text=True)
secret = re.search(r"<code>([A-Z0-9]+)</code>", html).group(1)
secret = not_none(re.search(r"<code>([A-Z0-9]+)</code>", html)).group(1)
code = pyotp.TOTP(secret).now()
return client.post("/register/2fa", data={"code": code}, follow_redirects=False), secret
def _complete_onboarding(client, name="Mon jeu", onboarding_type="rpg"):
def _complete_onboarding(client: FlaskClient, name: str = "Mon jeu", onboarding_type: str = "rpg") -> Any:
"""Termine l'onboarding guidé (voir routes/onboarding/onboarding_new.py)
— la plupart des tests de ce fichier n'ont pas besoin de tester
l'onboarding lui-même (voir test_onboarding.py), juste d'obtenir un
project_slug réel pour la suite ; "rpg" est le seul type restant."""
return client.post(
"/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False
)
return client.post("/onboarding", data={"onboarding_type": onboarding_type, "name": name}, follow_redirects=False)
def _cleanup_project(email):
def _cleanup_project(email: str) -> None:
import os
user = auth.get_user_by_email(email)
if user and user.get("project_slug"):
if os.path.isdir(db.game_dir(user["project_slug"])):
# db.delete_game (voir db/games/project_slug.py — structure de
# dossiers par utilisateur) nettoie aussi le dossier
# propriétaire s'il devient vide, contrairement à un simple
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
# vides s'accumulent sous projects/ d'un run de tests à l'autre
# (déjà observé : ça a fini par provoquer une collision de nom
# dans db.move_game, un dossier vide comptant comme "occupé").
db.delete_game(user["project_slug"])
if user and user.get("project_slug") and os.path.isdir(db.game_dir(user["project_slug"])):
# db.delete_game (voir db/games/project_slug.py — structure de
# dossiers par utilisateur) nettoie aussi le dossier
# propriétaire s'il devient vide, contrairement à un simple
# shutil.rmtree(game_dir) — sans ça, des dossiers propriétaires
# vides s'accumulent sous projects/ d'un run de tests à l'autre
# (déjà observé : ça a fini par provoquer une collision de nom
# dans db.move_game, un dossier vide comptant comme "occupé").
db.delete_game(user["project_slug"])
def test_anonymous_request_redirects_to_login(anon_client):
def test_anonymous_request_redirects_to_login(anon_client: FlaskClient) -> None:
resp = anon_client.get("/", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"].startswith("/login")
def test_registration_requires_matching_password_confirmation(anon_client):
resp = anon_client.post("/register", data={
"prenom": "A", "nom": "B", "email": "mismatch@example.com",
"password": "Sup3r$ecret!", "password_confirm": "Autre$ecret1",
})
def test_registration_requires_matching_password_confirmation(anon_client: FlaskClient) -> None:
resp = anon_client.post(
"/register",
data={
"prenom": "A",
"nom": "B",
"email": "mismatch@example.com",
"password": "Sup3r$ecret!",
"password_confirm": "Autre$ecret1",
},
)
assert resp.status_code == 200
assert "ne correspondent pas" in resp.get_data(as_text=True)
assert auth.get_user_by_email("mismatch@example.com") is None
def test_registration_rejects_a_weak_password(anon_client):
resp = anon_client.post("/register", data={
"prenom": "A", "nom": "B", "email": "weak@example.com",
"password": "abcdefgh", "password_confirm": "abcdefgh",
})
def test_registration_rejects_a_weak_password(anon_client: FlaskClient) -> None:
resp = anon_client.post(
"/register",
data={
"prenom": "A",
"nom": "B",
"email": "weak@example.com",
"password": "abcdefgh",
"password_confirm": "abcdefgh",
},
)
assert resp.status_code == 200
assert "faible" in resp.get_data(as_text=True)
assert auth.get_user_by_email("weak@example.com") is None
def test_registration_rejects_a_duplicate_email(anon_client):
def test_registration_rejects_a_duplicate_email(anon_client: FlaskClient) -> None:
_register(anon_client, "dup@example.com")
resp = _register(anon_client, "dup@example.com")
assert resp.status_code == 200
assert "existe déjà" in resp.get_data(as_text=True)
def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client):
def test_full_registration_flow_requires_totp_before_account_is_usable(anon_client: FlaskClient) -> None:
resp = _register(anon_client, "flow@example.com")
assert resp.status_code == 302
assert resp.headers["Location"] == "/register/2fa"
user = auth.get_user_by_email("flow@example.com")
user = not_none(auth.get_user_by_email("flow@example.com"))
assert user["totp_confirmed"] == 0
# Compte pas encore confirmé -> pas encore une vraie session connectée,
# même après l'étape de mot de passe (voir register.py : la connexion
@@ -119,7 +147,7 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie
resp, secret = _confirm_2fa(anon_client)
assert resp.status_code == 302
user = auth.get_user_by_email("flow@example.com")
user = not_none(auth.get_user_by_email("flow@example.com"))
assert user["totp_confirmed"] == 1
assert user["role"] == "user" # jamais le premier compte de la session de tests
# Onboarding guidé (voir routes/onboarding/onboarding_new.py) : plus
@@ -136,27 +164,27 @@ def test_full_registration_flow_requires_totp_before_account_is_usable(anon_clie
onboard_resp = _complete_onboarding(anon_client)
assert onboard_resp.status_code == 302
user = auth.get_user_by_email("flow@example.com")
user = not_none(auth.get_user_by_email("flow@example.com"))
assert user["project_slug"] is not None
assert onboard_resp.headers["Location"] == "/game/" + user["project_slug"]
finally:
_cleanup_project("flow@example.com")
def test_wrong_totp_code_is_rejected(anon_client):
def test_wrong_totp_code_is_rejected(anon_client: FlaskClient) -> None:
_register(anon_client, "badcode@example.com")
resp = anon_client.post("/register/2fa", data={"code": "000000"})
assert resp.status_code == 200
assert "invalide" in resp.get_data(as_text=True)
user = auth.get_user_by_email("badcode@example.com")
user = not_none(auth.get_user_by_email("badcode@example.com"))
assert user["totp_confirmed"] == 0
def test_login_requires_correct_password_then_totp(anon_client):
def test_login_requires_correct_password_then_totp(anon_client: FlaskClient) -> None:
_register(anon_client, "login@example.com")
resp, secret = _confirm_2fa(anon_client)
_complete_onboarding(anon_client)
slug = auth.get_user_by_email("login@example.com")["project_slug"]
slug = not_none(auth.get_user_by_email("login@example.com"))["project_slug"]
try:
anon_client.post("/logout")
@@ -164,7 +192,9 @@ def test_login_requires_correct_password_then_totp(anon_client):
assert resp.status_code == 200
assert "incorrect" in resp.get_data(as_text=True)
resp = anon_client.post("/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
resp = anon_client.post(
"/login", data={"email": "login@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False
)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
@@ -176,11 +206,11 @@ def test_login_requires_correct_password_then_totp(anon_client):
_cleanup_project("login@example.com")
def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
def test_non_admin_user_is_isolated_to_their_own_project(anon_client: FlaskClient) -> None:
_register(anon_client, "isolated@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("isolated@example.com")
user = not_none(auth.get_user_by_email("isolated@example.com"))
try:
# Un autre projet (celui de l'admin partagé par conftest.py, ou
# n'importe quel autre slug) doit rester inaccessible.
@@ -201,7 +231,7 @@ def test_non_admin_user_is_isolated_to_their_own_project(anon_client):
_cleanup_project("second")
def _create_isolated_user_with_project(email, name="Jeu de la victime"):
def _create_isolated_user_with_project(email: str, name: str = "Jeu de la victime") -> Any:
"""Compte + projet créés directement (sans passer par le client HTTP
de test) — combiner ici deux instances de `test_client()` actives en
parallèle (la fixture `client`, admin partagé, ET une seconde pour
@@ -215,7 +245,7 @@ def _create_isolated_user_with_project(email, name="Jeu de la victime"):
return auth.get_user_by_id(user_id)
def test_admin_cannot_access_another_users_project(client):
def test_admin_cannot_access_another_users_project(client: FlaskClient) -> None:
"""Faille corrigée : le rôle "admin" contournait entièrement
l'isolation par projet (voir core/auth_guard.py) — un admin pouvait
ouvrir/modifier/supprimer le jeu de N'IMPORTE QUEL autre compte en
@@ -233,7 +263,7 @@ def test_admin_cannot_access_another_users_project(client):
_cleanup_project("adminvictim@example.com")
def test_admin_home_page_only_lists_their_own_projects(client, game):
def test_admin_home_page_only_lists_their_own_projects(client: FlaskClient, game: str) -> None:
"""routes/games/index.py (page "Mes jeux") scannait auparavant TOUT
projects/ sans filtrage (db.list_games()) — n'importe quel admin
voyait donc le slug (et le nom de dossier propriétaire) de chaque
@@ -250,14 +280,14 @@ def test_admin_home_page_only_lists_their_own_projects(client, game):
_cleanup_project("adminlistvictim@example.com")
def test_non_admin_user_cannot_delete_their_only_project(anon_client):
def test_non_admin_user_cannot_delete_their_only_project(anon_client: FlaskClient) -> None:
"""Sans issue de secours (games_new renvoie toujours vers son
project_slug, existant ou non), le supprimer serait un piège sans
retour — bloqué plutôt que risqué."""
_register(anon_client, "nodelete@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("nodelete@example.com")
user = not_none(auth.get_user_by_email("nodelete@example.com"))
try:
resp = anon_client.post(f"/game/{user['project_slug']}/delete")
assert resp.status_code == 403
@@ -266,23 +296,22 @@ def test_non_admin_user_cannot_delete_their_only_project(anon_client):
_cleanup_project("nodelete@example.com")
def test_lockout_escalation_is_5_10_20_40_capped_at_60():
def test_lockout_escalation_is_5_10_20_40_capped_at_60() -> None:
assert [auth.lockout_minutes_for(n) for n in range(9)] == [0, 0, 0, 0, 5, 10, 20, 40, 60]
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client):
def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client: FlaskClient) -> None:
"""3 tentatives libres, puis un verrouillage qui double à chaque
nouvel échec (5, 10, 20 min...) — voir auth/rate_limit.py."""
_register(anon_client, "bruteforce@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce@example.com")
try:
anon_client.post("/logout")
for _ in range(3):
resp = anon_client.post("/login", data={"email": "bruteforce@example.com", "password": "wrong"})
assert "incorrect" in resp.get_data(as_text=True)
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 3
assert not_none(auth.get_user_by_email("bruteforce@example.com"))["failed_attempts"] == 3
assert auth.seconds_locked_remaining(auth.get_user_by_email("bruteforce@example.com")) == 0
# 4e échec -> premier verrouillage (5 minutes).
@@ -299,15 +328,14 @@ def test_login_locks_out_after_3_free_attempts_then_escalates(anon_client):
# Un essai bloqué par le verrouillage n'incrémente pas davantage
# le compteur (pas d'escalade supplémentaire tant qu'on est
# encore dans la fenêtre déjà posée).
assert auth.get_user_by_email("bruteforce@example.com")["failed_attempts"] == 4
assert not_none(auth.get_user_by_email("bruteforce@example.com"))["failed_attempts"] == 4
finally:
_cleanup_project("bruteforce@example.com")
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client):
def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client: FlaskClient) -> None:
_register(anon_client, "bruteforce2fa@example.com")
resp, secret = _confirm_2fa(anon_client)
user = auth.get_user_by_email("bruteforce2fa@example.com")
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "bruteforce2fa@example.com", "password": "Sup3r$ecret!"})
@@ -326,28 +354,28 @@ def test_login_2fa_wrong_code_also_counts_towards_the_same_lockout(anon_client):
_cleanup_project("bruteforce2fa@example.com")
def test_successful_login_resets_the_failed_attempts_counter(anon_client):
def test_successful_login_resets_the_failed_attempts_counter(anon_client: FlaskClient) -> None:
_register(anon_client, "resetcounter@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
anon_client.post("/logout")
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "wrong"})
assert auth.get_user_by_email("resetcounter@example.com")["failed_attempts"] == 2
assert not_none(auth.get_user_by_email("resetcounter@example.com"))["failed_attempts"] == 2
anon_client.post("/login", data={"email": "resetcounter@example.com", "password": "Sup3r$ecret!"})
code = pyotp.TOTP(secret).now()
resp = anon_client.post("/login/2fa", data={"code": code}, follow_redirects=False)
assert resp.status_code == 302
user = auth.get_user_by_email("resetcounter@example.com")
user = not_none(auth.get_user_by_email("resetcounter@example.com"))
assert user["failed_attempts"] == 0
assert user["locked_until"] is None
finally:
_cleanup_project("resetcounter@example.com")
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client: FlaskClient) -> None:
_register(anon_client, "recoveryshown@example.com")
resp, secret = _confirm_2fa(anon_client)
try:
@@ -362,11 +390,11 @@ def test_recovery_codes_are_shown_once_after_confirming_2fa(anon_client):
_cleanup_project("recoveryshown@example.com")
def test_login_with_a_recovery_code_instead_of_totp(anon_client):
def test_login_with_a_recovery_code_instead_of_totp(anon_client: FlaskClient) -> None:
_register(anon_client, "recoverylogin@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("recoverylogin@example.com")
user = not_none(auth.get_user_by_email("recoverylogin@example.com"))
slug = user["project_slug"]
codes = auth.generate_recovery_codes(user["id"])
try:
@@ -392,7 +420,7 @@ def test_login_with_a_recovery_code_instead_of_totp(anon_client):
_cleanup_project("recoverylogin@example.com")
def test_admin_role_is_not_restricted_to_a_single_project(client, game):
def test_admin_role_is_not_restricted_to_a_single_project(client: FlaskClient, game: str) -> None:
"""`client`/`game` (conftest.py) sont déjà connectés en tant qu'admin
de la session de tests — la création d'un second jeu, déjà exercée par
des dizaines d'autres tests via la fixture `game`, doit continuer de
@@ -401,8 +429,9 @@ def test_admin_role_is_not_restricted_to_a_single_project(client, game):
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
assert slug != game
import shutil
import os
import shutil
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
+36 -18
View File
@@ -5,31 +5,41 @@ screens/labels/background_library.py), derrière tout le reste
scène, "monde" que la caméra suit en défilant derrière le personnage
(voir static/js/play/personnage-controller.js, testé côté client dans
static/js/play/__tests__/personnage-controller.test.js)."""
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_background"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_background"
) -> 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 _a_background_slug():
def _a_background_slug() -> Any:
assert screens.BACKGROUND_LIBRARY, "aucune image de fond générée — lancer scripts/generate_background_manifest.py"
return next(iter(screens.BACKGROUND_LIBRARY))
def test_background_library_is_built_from_the_generated_manifest():
def test_background_library_is_built_from_the_generated_manifest() -> None:
slug = _a_background_slug()
background = screens.BACKGROUND_LIBRARY[slug]
assert background["url"].startswith("/static/backgrounds/")
assert background["width"] > 0 and background["height"] > 0
def test_adding_a_fond_object_uses_the_image_real_size_and_sits_behind_everything(client, tmp_game_slug_cleanup):
def test_adding_a_fond_object_uses_the_image_real_size_and_sits_behind_everything(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
background_slug = _a_background_slug()
background = screens.BACKGROUND_LIBRARY[background_slug]
@@ -40,9 +50,9 @@ def test_adding_a_fond_object_uses_the_image_real_size_and_sits_behind_everythin
follow_redirects=False,
)
assert resp.status_code == 302, resp.data
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["kind"] == "fond"
assert obj["width"] == background["width"]
assert obj["height"] == background["height"]
@@ -51,7 +61,9 @@ def test_adding_a_fond_object_uses_the_image_real_size_and_sits_behind_everythin
assert obj["attributes"]["src"] == background["url"]
def test_fond_object_renders_as_an_image_with_the_sceneBackground_class(client, tmp_game_slug_cleanup):
def test_fond_object_renders_as_an_image_with_the_sceneBackground_class(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
background_slug = _a_background_slug()
resp = client.post(
@@ -59,14 +71,16 @@ def test_fond_object_renders_as_an_image_with_the_sceneBackground_class(client,
data={"kind": "fond", "background_slug": background_slug},
follow_redirects=False,
)
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
obj = screens.get_scene_object(slug, obj_id)
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'class="sceneObjectSprite sceneBackground"' in html
assert f'data-object-id="{obj_id}"' in html
def test_fond_object_falls_back_to_default_size_without_a_known_background_slug(client, tmp_game_slug_cleanup):
def test_fond_object_falls_back_to_default_size_without_a_known_background_slug(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Un slug invalide/absent (URL bidouillée, script de manifeste jamais
lancé sur ce poste) : l'objet est quand même créé, à la taille par
défaut générique (128x128, voir ensure_scene_schema.py) plutôt que de
@@ -78,14 +92,16 @@ def test_fond_object_falls_back_to_default_size_without_a_known_background_slug(
follow_redirects=False,
)
assert resp.status_code == 302
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
obj = screens.get_scene_object(slug, obj_id)
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["width"] == 128 and obj["height"] == 128
def test_background_gallery_route_rejects_a_non_admin_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False)
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def test_background_gallery_route_rejects_a_non_admin_account(user_game: str, user_client: FlaskClient) -> None:
resp = user_client.post(
f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False
)
screen_id = int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
background_slug = _a_background_slug()
resp = user_client.post(
f"/game/{user_game}/screens/{screen_id}/scene-objects/add",
@@ -95,8 +111,10 @@ def test_background_gallery_route_rejects_a_non_admin_account(user_game, user_cl
assert resp.status_code == 403
def test_background_gallery_hidden_from_non_admin_scene_editor(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False)
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def test_background_gallery_hidden_from_non_admin_scene_editor(user_game: str, user_client: FlaskClient) -> None:
resp = user_client.post(
f"/game/{user_game}/screens/new", data={"name": "Scène", "kind": "jeu_2d"}, follow_redirects=False
)
screen_id = int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "Images de fond" not in html
+87 -34
View File
@@ -1,56 +1,79 @@
"""Tests du bornage automatique des champs numériques (2.2)."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _create_numeric_object(client, slug, field_name="reputation", min_v="0", max_v="100", start="50"):
field = {"name": field_name, "type": "nombre_entier"}
def _create_numeric_object(
client: FlaskClient,
slug: str,
field_name: str = "reputation",
min_v: str = "0",
max_v: str = "100",
start: str = "50",
) -> Any:
field: dict[str, Any] = {"name": field_name, "type": "nombre_entier"}
if min_v != "":
field["min_value"] = min_v
if max_v != "":
field["max_value"] = max_v
def_id = db.create_definition(slug, "Partie", [field])
definition = db.get_definition(slug, def_id)
definition = not_none(db.get_definition(slug, def_id))
db.insert_row(slug, definition, {field_name: start})
return def_id
def _get_row_value(client, slug, def_id, field_name):
def _get_row_value(client: FlaskClient, slug: str, def_id: int, field_name: str) -> Any:
import db
definition = db.get_definition(slug, def_id)
definition = not_none(db.get_definition(slug, def_id))
rows = db.list_rows(slug, definition)
return rows[0].get(db.slugify(field_name).replace("-", "_"))
def test_field_bounds_are_stored_at_creation(client, game):
def test_field_bounds_are_stored_at_creation(client: FlaskClient, game: str) -> None:
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "50")
import db
definition = db.get_definition(game, def_id)
definition = not_none(db.get_definition(game, def_id))
field = definition["fields"][0]
assert field["min_value"] == 0
assert field["max_value"] == 100
def test_incrementer_clamps_to_max(client, game):
def test_incrementer_clamps_to_max(client: FlaskClient, game: str) -> None:
"""Régression 2.2 : une action "Augmenter de..." ne doit jamais faire
dépasser la borne maxi réglée sur le champ, même si le montant ajouté
dépasse largement ce qu'il reste avant la limite."""
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "95")
row_value = _get_row_value(client, game, def_id, "reputation")
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
"data_operation": "incrementer", "data_value": "50",
}), content_type="application/json")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": 1,
"target_field": "reputation",
"data_operation": "incrementer",
"data_value": "50",
}
),
content_type="application/json",
)
node = resp.get_json()
resp = client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
@@ -60,16 +83,26 @@ def test_incrementer_clamps_to_max(client, game):
assert new_value == 100, f"la valeur aurait dû être bornée à 100, vaut {new_value}"
def test_decrementer_clamps_to_min(client, game):
def test_decrementer_clamps_to_min(client: FlaskClient, game: str) -> None:
"""Régression 2.2 : symétrique pour "Diminuer de..." et la borne mini."""
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "5")
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
"data_operation": "decrementer", "data_value": "50",
}), content_type="application/json")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": 1,
"target_field": "reputation",
"data_operation": "decrementer",
"data_value": "50",
}
),
content_type="application/json",
)
node = resp.get_json()
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
@@ -77,17 +110,27 @@ def test_decrementer_clamps_to_min(client, game):
assert new_value == 0, f"la valeur aurait dû être bornée à 0, vaut {new_value}"
def test_definir_nombre_also_clamped(client, game):
def test_definir_nombre_also_clamped(client: FlaskClient, game: str) -> None:
"""Régression 2.2 : "Donner cette valeur (nombre)" doit aussi être
bornée, pas seulement les incréments/décréments."""
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "50")
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
"data_operation": "definir_nombre", "data_value": "9999",
}), content_type="application/json")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": 1,
"target_field": "reputation",
"data_operation": "definir_nombre",
"data_value": "9999",
}
),
content_type="application/json",
)
node = resp.get_json()
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
@@ -95,17 +138,27 @@ def test_definir_nombre_also_clamped(client, game):
assert new_value == 100
def test_no_bounds_set_means_unlimited(client, game):
def test_no_bounds_set_means_unlimited(client: FlaskClient, game: str) -> None:
"""Sans bornes réglées (champ laissé vide), le comportement doit rester
celui d'avant 2.2 : aucune limite appliquée."""
def_id = _create_numeric_object(client, game, "reputation", "", "", "50")
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
"data_operation": "incrementer", "data_value": "500",
}), content_type="application/json")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": 1,
"target_field": "reputation",
"data_operation": "incrementer",
"data_value": "500",
}
),
content_type="application/json",
)
node = resp.get_json()
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
+27 -9
View File
@@ -4,25 +4,37 @@
sa taille". Rendu SEULEMENT pour l'objet sélectionné (voir
static/js/scenes/scene-editor.js::onCollisionBoxMouseDown/
onCollisionBoxResizeMouseDown pour le glisser-déplacer/redimensionner)."""
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_overlay"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_overlay"
) -> 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 _add_decor(client, slug, screen_id):
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False)
def _add_decor(client: FlaskClient, slug: str, screen_id: int) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False
)
assert resp.status_code == 302, resp.data
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
def test_selected_object_renders_the_collision_overlay_with_drag_handlers(client, tmp_game_slug_cleanup):
def test_selected_object_renders_the_collision_overlay_with_drag_handlers(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
@@ -32,7 +44,9 @@ def test_selected_object_renders_the_collision_overlay_with_drag_handlers(client
assert f"onCollisionBoxResizeMouseDown(event, {obj_id})" in html
def test_only_the_selected_object_gets_an_overlay(client, tmp_game_slug_cleanup):
def test_only_the_selected_object_gets_an_overlay(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
_add_decor(client, slug, screen_id) # un second objet, jamais sélectionné ici
@@ -41,7 +55,9 @@ def test_only_the_selected_object_gets_an_overlay(client, tmp_game_slug_cleanup)
assert html.count('id="collisionBoxOverlay"') == 1
def test_overlay_reflects_a_custom_collision_box_size_and_offset(client, tmp_game_slug_cleanup):
def test_overlay_reflects_a_custom_collision_box_size_and_offset(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
client.post(
@@ -54,9 +70,11 @@ def test_overlay_reflects_a_custom_collision_box_size_and_offset(client, tmp_gam
assert "width:40.0px; height:40.0px" in html
def test_disabled_collision_marks_the_overlay(client, tmp_game_slug_cleanup):
def test_disabled_collision_marks_the_overlay(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
client.post(f"/game/{slug}/scene-objects/{obj_id}/collision", data='{"enabled": false}', content_type="application/json")
client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision", data='{"enabled": false}', content_type="application/json"
)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert "is-disabled" in html
File diff suppressed because it is too large Load Diff
+48 -18
View File
@@ -4,32 +4,44 @@ elementsOverlap pour son usage au jeu, testé côté client dans
static/js/play/__tests__/conditions.test.js) : posée AUTOMATIQUEMENT sur
CHAQUE objet (= sa propre boîte, tant que rien n'est réglé), activable/
désactivable, taille/forme/décalage configurables."""
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_collision"
) -> 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 _add_decor(client, slug, screen_id):
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False)
def _add_decor(client: FlaskClient, slug: str, screen_id: int) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "decor"}, follow_redirects=False
)
assert resp.status_code == 302, resp.data
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
def test_freshly_placed_object_defaults_to_its_own_box_as_collision(client, tmp_game_slug_cleanup):
def test_freshly_placed_object_defaults_to_its_own_box_as_collision(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Sans réglage explicite, la boîte de collision EST la boîte de
l'objet (comportement inchangé pour tout objet déjà posé — la
collision se comportait déjà comme ça, implicitement, avant l'ajout
de ce réglage)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
collision = screens.resolve_collision_settings(obj)
assert collision["enabled"] is True
assert collision["shape"] == "rectangle"
@@ -38,7 +50,9 @@ def test_freshly_placed_object_defaults_to_its_own_box_as_collision(client, tmp_
assert collision["offset_x"] == 0 and collision["offset_y"] == 0
def test_collision_route_persists_a_custom_box(client, tmp_game_slug_cleanup):
def test_collision_route_persists_a_custom_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
@@ -49,53 +63,69 @@ def test_collision_route_persists_a_custom_box(client, tmp_game_slug_cleanup):
)
assert resp.status_code == 200
assert resp.get_json()["collision"] == {
"enabled": True, "shape": "cercle", "width": 30.0, "height": 30.0, "offset_x": 5.0, "offset_y": -5.0,
"enabled": True,
"shape": "cercle",
"width": 30.0,
"height": 30.0,
"offset_x": 5.0,
"offset_y": -5.0,
}
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_collision_settings(obj)["shape"] == "cercle"
def test_collision_route_can_disable_the_box_entirely(client, tmp_game_slug_cleanup):
def test_collision_route_can_disable_the_box_entirely(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"enabled": false}', content_type="application/json",
data='{"enabled": false}',
content_type="application/json",
)
assert resp.get_json()["collision"]["enabled"] is False
def test_collision_route_rejects_an_unknown_shape_and_falls_back_to_rectangle(client, tmp_game_slug_cleanup):
def test_collision_route_rejects_an_unknown_shape_and_falls_back_to_rectangle(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"shape": "triangle"}', content_type="application/json",
data='{"shape": "triangle"}',
content_type="application/json",
)
assert resp.get_json()["collision"]["shape"] == "rectangle"
def test_collision_route_ignores_invalid_size_values(client, tmp_game_slug_cleanup):
def test_collision_route_ignores_invalid_size_values(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Une largeur/hauteur invalide (texte, vide) ne plante pas — retombe
sur la taille de l'objet, comme si rien n'avait été réglé."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_decor(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/collision",
data='{"width": "pas-un-nombre", "height": ""}', content_type="application/json",
data='{"width": "pas-un-nombre", "height": ""}',
content_type="application/json",
)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
collision = resp.get_json()["collision"]
assert collision["width"] == obj["width"]
assert collision["height"] == obj["height"]
def test_collision_route_404s_for_missing_object(client, game):
def test_collision_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/scene-objects/999999/collision", data="{}", content_type="application/json")
assert resp.status_code == 404
def test_full_game_payload_exposes_collision_settings_for_every_scene_object_kind(client, tmp_game_slug_cleanup):
def test_full_game_payload_exposes_collision_settings_for_every_scene_object_kind(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Pas seulement "personnage" — un décor/fond peut aussi servir
d'obstacle à une condition de collision."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
+21 -19
View File
@@ -4,113 +4,115 @@ apply_variable_action.py (variable globale), factorisée pour ne plus être
dupliquée dans les deux fichiers. Verrouille le comportement des 7
opérations historiques ET des 6 opérations ajoutées en Phase 2 (hasard +
opérations mathématiques)."""
import pytest
from screens.data_actions.compute_operation import compute_new_value
def test_definir_texte():
def test_definir_texte() -> None:
assert compute_new_value("definir_texte", "ancien", "nouveau", False) == "nouveau"
assert compute_new_value("definir_texte", "ancien", "", False) == ""
assert compute_new_value("definir_texte", "ancien", None, False) == ""
def test_definir_nombre_entier_et_decimal():
def test_definir_nombre_entier_et_decimal() -> None:
assert compute_new_value("definir_nombre", 0, "5", False) == 5
assert isinstance(compute_new_value("definir_nombre", 0, "5", False), int)
assert compute_new_value("definir_nombre", 0, "5.7", True) == 5.7
assert compute_new_value("definir_nombre", 0, "pas un nombre", False) == 0
def test_definir_bool():
def test_definir_bool() -> None:
assert compute_new_value("definir_bool_vrai", 0, None, False) == 1
assert compute_new_value("definir_bool_faux", 1, None, False) == 0
def test_basculer_bool():
def test_basculer_bool() -> None:
assert compute_new_value("basculer_bool", 0, None, False) == 1
assert compute_new_value("basculer_bool", 1, None, False) == 0
assert compute_new_value("basculer_bool", None, None, False) == 1
def test_incrementer_decrementer_entier():
def test_incrementer_decrementer_entier() -> None:
assert compute_new_value("incrementer", 10, "5", False) == 15
assert compute_new_value("decrementer", 10, "5", False) == 5
assert compute_new_value("incrementer", None, "5", False) == 5
def test_incrementer_decimal_garde_les_decimales():
def test_incrementer_decimal_garde_les_decimales() -> None:
result = compute_new_value("incrementer", 10.0, "0.5", True)
assert result == 10.5
assert isinstance(result, float)
def test_incrementer_valeur_invalide_ne_change_rien():
def test_incrementer_valeur_invalide_ne_change_rien() -> None:
assert compute_new_value("incrementer", 10, "pas un nombre", False) == 10
def test_operation_inconnue_leve_value_error():
def test_operation_inconnue_leve_value_error() -> None:
with pytest.raises(ValueError):
compute_new_value("teleporter", 10, "2", False)
# ---------- Phase 2 — hasard + opérations mathématiques ----------
def test_multiplier():
def test_multiplier() -> None:
assert compute_new_value("multiplier", 10, "3", False) == 30
assert compute_new_value("multiplier", 4.0, "2.5", True) == 10.0
def test_diviser():
def test_diviser() -> None:
assert compute_new_value("diviser", 10, "2", False) == 5
assert compute_new_value("diviser", 5.0, "2", True) == 2.5
def test_diviser_par_zero_ne_change_rien():
def test_diviser_par_zero_ne_change_rien() -> None:
assert compute_new_value("diviser", 10, "0", False) == 10
def test_modulo():
def test_modulo() -> None:
assert compute_new_value("modulo", 10, "3", False) == 1
assert compute_new_value("modulo", 10, "0", False) == 10 # garde-fou division par zéro
def test_minimum_releve_la_valeur_si_en_dessous():
def test_minimum_releve_la_valeur_si_en_dessous() -> None:
assert compute_new_value("minimum", 3, "10", False) == 10
assert compute_new_value("minimum", 15, "10", False) == 15 # déjà au-dessus : inchangé
def test_maximum_plafonne_la_valeur_si_au_dessus():
def test_maximum_plafonne_la_valeur_si_au_dessus() -> None:
assert compute_new_value("maximum", 15, "10", False) == 10
assert compute_new_value("maximum", 3, "10", False) == 3 # déjà en dessous : inchangé
def test_minimum_maximum_valeur_invalide_ne_change_rien():
def test_minimum_maximum_valeur_invalide_ne_change_rien() -> None:
assert compute_new_value("minimum", 5, "pas un nombre", False) == 5
assert compute_new_value("maximum", 5, "", False) == 5
def test_alea_entier_dans_les_bornes():
def test_alea_entier_dans_les_bornes() -> None:
for _ in range(50):
result = compute_new_value("alea", 0, "1,6", False)
assert isinstance(result, int)
assert 1 <= result <= 6
def test_alea_decimal_dans_les_bornes():
def test_alea_decimal_dans_les_bornes() -> None:
for _ in range(50):
result = compute_new_value("alea", 0, "1.5,2.5", True)
assert isinstance(result, float)
assert 1.5 <= result <= 2.5
def test_alea_bornes_inversees_sont_remises_dans_lordre():
def test_alea_bornes_inversees_sont_remises_dans_lordre() -> None:
for _ in range(50):
result = compute_new_value("alea", 0, "6,1", False)
assert 1 <= result <= 6
def test_alea_valeur_invalide_retombe_sur_zero():
def test_alea_valeur_invalide_retombe_sur_zero() -> None:
assert compute_new_value("alea", 0, "pas un nombre", False) == 0
assert compute_new_value("alea", 0, "", False) == 0
assert compute_new_value("alea", 0, "5", False) == 0 # une seule borne : incomplet
+40 -17
View File
@@ -10,28 +10,38 @@ scalaire, objet avec chemin chaîné, tableau par index, variable
introuvable (ne matche rien plutôt que planter), booléen "Oui", et clause
sans "source" (comportement legacy, champ d'objet) — les 7 scénarios
passent."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def test_condition_node_stores_variable_source(client, game):
def test_condition_node_stores_variable_source(client: FlaskClient, game: str) -> None:
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition", "cond_source": "variable",
"cond_variable": "score", "cond_variable_chemin": "",
"cond_operator": "superieur_egal", "cond_value": "3",
}),
data=json.dumps(
{
"node_type": "condition",
"cond_source": "variable",
"cond_variable": "score",
"cond_variable_chemin": "",
"cond_operator": "superieur_egal",
"cond_value": "3",
}
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -44,21 +54,32 @@ def test_condition_node_stores_variable_source(client, game):
assert node["cond_field"] is None
def test_condition_node_with_combined_variable_and_object_clauses(client, game):
def test_condition_node_with_combined_variable_and_object_clauses(client: FlaskClient, game: str) -> None:
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
def_id = db.create_definition(game, "Niveau", [{"name": "numero", "type": "nombre_entier"}])
screen_id = _create_screen(client, game)
clauses = [
{"source": "variable", "variable": "score", "operator": "superieur_egal", "value": "3"},
{"source": "objet", "definition_id": def_id, "row_id": None, "field": "numero",
"field_type": "nombre_entier", "operator": "egal", "value": "1"},
{
"source": "objet",
"definition_id": def_id,
"row_id": None,
"field": "numero",
"field_type": "nombre_entier",
"operator": "egal",
"value": "1",
},
]
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition", "cond_clauses": json.dumps(clauses), "cond_combinator": "ou",
}),
data=json.dumps(
{
"node_type": "condition",
"cond_clauses": json.dumps(clauses),
"cond_combinator": "ou",
}
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -69,10 +90,12 @@ def test_condition_node_with_combined_variable_and_object_clauses(client, game):
assert node["cond_combinator"] == "ou"
def test_runtime_payload_exposes_global_variables_for_condition_evaluation(client, game):
def test_runtime_payload_exposes_global_variables_for_condition_evaluation(client: FlaskClient, game: str) -> None:
client.post(f"/game/{game}/variables/new", data={"name": "score", "type": "nombre_entier", "default_value": "5"})
client.post(f"/game/{game}/variables/new", data={"name": "perso", "type": "objet",
"default_value": json.dumps({"nom": "Alice"})})
client.post(
f"/game/{game}/variables/new",
data={"name": "perso", "type": "objet", "default_value": json.dumps({"nom": "Alice"})},
)
payload = client.get(f"/game/{game}/runtime-payload").get_json()
assert payload["variables"]["score"] == {"value": "5", "type": "nombre_entier"}
assert payload["variables"]["perso"]["type"] == "objet"
+73 -35
View File
@@ -7,43 +7,56 @@ EXACTEMENT son comportement d'avant (une seule comparaison) — c'est ce que
vérifient les tests de non-régression ci-dessous, en plus des tests dédiés
à la nouvelle fonctionnalité.
"""
import json
import re
from typing import Any
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import db
from conftest import play_js_bundle
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _create_object_two_fields(client, slug):
def _create_object_two_fields(client: FlaskClient, slug: str) -> Any:
"""Crée un objet "Partie" avec deux champs numériques et une ligne."""
def_id = db.create_definition(slug, "Partie", [
{"name": "reputation", "type": "nombre_entier"},
{"name": "niveau", "type": "nombre_entier"},
])
definition = db.get_definition(slug, def_id)
def_id = db.create_definition(
slug,
"Partie",
[
{"name": "reputation", "type": "nombre_entier"},
{"name": "niveau", "type": "nombre_entier"},
],
)
definition = not_none(db.get_definition(slug, def_id))
db.insert_row(slug, definition, {"reputation": "5", "niveau": "3"})
return def_id
def test_legacy_single_clause_condition_still_works_without_cond_clauses(client, game):
def test_legacy_single_clause_condition_still_works_without_cond_clauses(client: FlaskClient, game: str) -> None:
"""Non-régression : un nœud Condition créé SANS cond_clauses (comme avant
2.4) doit toujours fonctionner à l'identique."""
screen_id = _create_screen(client, game)
def_id = _create_object_two_fields(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition",
"cond_definition_id": def_id, "cond_row_id": 1,
"cond_field": "reputation", "cond_field_type": "nombre_entier",
"cond_operator": "superieur", "cond_value": "10",
}),
data=json.dumps(
{
"node_type": "condition",
"cond_definition_id": def_id,
"cond_row_id": 1,
"cond_field": "reputation",
"cond_field_type": "nombre_entier",
"cond_operator": "superieur",
"cond_value": "10",
}
),
content_type="application/json",
)
node = resp.get_json()
@@ -53,22 +66,42 @@ def test_legacy_single_clause_condition_still_works_without_cond_clauses(client,
assert "cond_clauses" in js
def test_condition_node_persists_clauses_and_combinator(client, game):
def test_condition_node_persists_clauses_and_combinator(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
def_id = _create_object_two_fields(client, game)
clauses = [
{"definition_id": def_id, "row_id": 1, "field": "reputation", "field_type": "nombre_entier", "operator": "inferieur", "value": "20"},
{"definition_id": def_id, "row_id": 1, "field": "niveau", "field_type": "nombre_entier", "operator": "superieur_egal", "value": "3"},
{
"definition_id": def_id,
"row_id": 1,
"field": "reputation",
"field_type": "nombre_entier",
"operator": "inferieur",
"value": "20",
},
{
"definition_id": def_id,
"row_id": 1,
"field": "niveau",
"field_type": "nombre_entier",
"operator": "superieur_egal",
"value": "3",
},
]
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition",
"cond_definition_id": def_id, "cond_row_id": 1,
"cond_field": "reputation", "cond_field_type": "nombre_entier",
"cond_operator": "inferieur", "cond_value": "20",
"cond_clauses": json.dumps(clauses), "cond_combinator": "ou",
}),
data=json.dumps(
{
"node_type": "condition",
"cond_definition_id": def_id,
"cond_row_id": 1,
"cond_field": "reputation",
"cond_field_type": "nombre_entier",
"cond_operator": "inferieur",
"cond_value": "20",
"cond_clauses": json.dumps(clauses),
"cond_combinator": "ou",
}
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -80,26 +113,31 @@ def test_condition_node_persists_clauses_and_combinator(client, game):
assert stored_clauses[1]["field"] == "niveau"
def test_condition_combinator_defaults_to_et_when_absent(client, game):
def test_condition_combinator_defaults_to_et_when_absent(client: FlaskClient, game: str) -> None:
"""cond_combinator a une valeur par défaut 'et' en base même si on ne la
précise jamais (jeux migrés depuis avant 2.4)."""
screen_id = _create_screen(client, game)
def_id = _create_object_two_fields(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition",
"cond_definition_id": def_id, "cond_row_id": 1,
"cond_field": "reputation", "cond_field_type": "nombre_entier",
"cond_operator": "superieur", "cond_value": "10",
}),
data=json.dumps(
{
"node_type": "condition",
"cond_definition_id": def_id,
"cond_row_id": 1,
"cond_field": "reputation",
"cond_field_type": "nombre_entier",
"cond_operator": "superieur",
"cond_value": "10",
}
),
content_type="application/json",
)
node = resp.get_json()
assert node["cond_combinator"] == "et"
def test_play_page_exposes_combinator_logic(client, game):
def test_play_page_exposes_combinator_logic(client: FlaskClient, game: str) -> None:
js = play_js_bundle()
assert "cond_combinator" in js
assert "'ou'" in js
+26 -13
View File
@@ -5,13 +5,18 @@ autres tests du moteur qui appellent les routes directement via le client
de test Flask. Ces tests-ci réactivent volontairement la vérification
(flask_app.config["TESTING"] = False) pour la mettre à l'épreuve pour de
vrai, puis la remettent dans l'état attendu par le reste de la suite."""
from collections.abc import Generator
from typing import Any
import pytest
from flask.testing import FlaskClient
from core.flask_app import app as flask_app
@pytest.fixture
def csrf_client():
def csrf_client() -> Generator[FlaskClient, None, None]:
"""Comme `client` (conftest.py), mais avec la vérification CSRF
RÉACTIVÉE le temps du test — connecté en tant qu'admin de test
(session_transaction), pour isoler la seule variable qui nous
@@ -28,30 +33,31 @@ def csrf_client():
flask_app.config["TESTING"] = True
def _get_session_csrf_token(client):
def _get_session_csrf_token(client: FlaskClient) -> Any:
with client.session_transaction() as sess:
return sess.get("csrf_token")
def test_get_requests_are_never_blocked_by_csrf(csrf_client):
def test_get_requests_are_never_blocked_by_csrf(csrf_client: FlaskClient) -> None:
resp = csrf_client.get("/")
assert resp.status_code == 200
def test_post_without_any_csrf_token_is_rejected(csrf_client):
def test_post_without_any_csrf_token_is_rejected(csrf_client: FlaskClient) -> None:
resp = csrf_client.post("/games/new", data={"name": "should not be created"})
assert resp.status_code == 400
def test_post_with_a_wrong_csrf_token_is_rejected(csrf_client):
def test_post_with_a_wrong_csrf_token_is_rejected(csrf_client: FlaskClient) -> None:
resp = csrf_client.post(
"/games/new", data={"name": "should not be created"},
"/games/new",
data={"name": "should not be created"},
headers={"X-CSRFToken": "not-the-right-token"},
)
assert resp.status_code == 400
def test_post_with_the_correct_csrf_token_succeeds(csrf_client):
def test_post_with_the_correct_csrf_token_succeeds(csrf_client: FlaskClient) -> None:
# Une requête GET fait générer/exposer le jeton de CETTE session (voir
# core/csrf.py::get_csrf_token, appelé par base.html au rendu).
csrf_client.get("/")
@@ -59,21 +65,25 @@ def test_post_with_the_correct_csrf_token_succeeds(csrf_client):
assert token
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_ok"},
headers={"X-CSRFToken": token}, follow_redirects=False,
"/games/new",
data={"name": "pytest_csrf_ok"},
headers={"X-CSRFToken": token},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
import db
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
def test_post_with_the_token_in_the_form_field_also_works(csrf_client):
def test_post_with_the_token_in_the_form_field_also_works(csrf_client: FlaskClient) -> None:
"""Filet de sécurité pour un éventuel formulaire natif qui ne passerait
pas par static/csrf_fetch.js (aucun cas de ce genre actuellement dans
le moteur, voir core/csrf_guard.py) — le champ de formulaire
@@ -82,15 +92,18 @@ def test_post_with_the_token_in_the_form_field_also_works(csrf_client):
token = _get_session_csrf_token(csrf_client)
resp = csrf_client.post(
"/games/new", data={"name": "pytest_csrf_form_field", "csrf_token": token},
"/games/new",
data={"name": "pytest_csrf_form_field", "csrf_token": token},
follow_redirects=False,
)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
import db
import os
import shutil
import db
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
+66 -28
View File
@@ -11,19 +11,24 @@ déclencher ne fait que signaler, jamais choisir un élément — c'est à
l'ÉCOUTEUR (déclencheur → condition → action) de décider quoi faire
ensuite, avec ses propres réglages habituels (cible fixe, "Ligne
cliquée"...)."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -33,11 +38,16 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def test_create_event_is_idempotent_by_name(client, game):
def test_create_event_is_idempotent_by_name(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/events/new", data={
"name": "NEED_ACTION", "description": "Besoin d'action", "screen_id": screen_id,
})
resp = client.post(
f"/game/{game}/events/new",
data={
"name": "NEED_ACTION",
"description": "Besoin d'action",
"screen_id": screen_id,
},
)
assert resp.status_code == 302
events = db.list_custom_events(game)
assert len(events) == 1
@@ -48,27 +58,37 @@ def test_create_event_is_idempotent_by_name(client, game):
assert len(db.list_custom_events(game)) == 1
def test_edit_event_updates_description(client, game):
def test_edit_event_updates_description(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
event_id = db.create_custom_event(game, "NEED_ACTION", "old")
resp = client.post(f"/game/{game}/events/{event_id}/edit", data={
"description": "new", "screen_id": screen_id,
})
resp = client.post(
f"/game/{game}/events/{event_id}/edit",
data={
"description": "new",
"screen_id": screen_id,
},
)
assert resp.status_code == 302
event = db.get_custom_event(game, event_id)
event = not_none(db.get_custom_event(game, event_id))
assert event["description"] == "new"
def test_usages_reports_listener_and_dispatcher_across_different_screens(client, game):
event_id = db.create_custom_event(game, "NEED_ACTION")
def test_usages_reports_listener_and_dispatcher_across_different_screens(client: FlaskClient, game: str) -> None:
event_id = not_none(db.create_custom_event(game, "NEED_ACTION"))
listener_screen = _create_screen(client, game, "Écran écouteur")
_add_flow_node(client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id)
_add_flow_node(
client, game, listener_screen, node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id
)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
_add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
client,
game,
dispatcher_screen,
node_type="action",
action_type="declencher_evenement",
target_custom_event_id=event_id,
)
usages = screens.list_custom_event_usages(game)
@@ -78,24 +98,34 @@ def test_usages_reports_listener_and_dispatcher_across_different_screens(client,
assert screen_ids == {listener_screen, dispatcher_screen}
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client, game):
def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client: FlaskClient, game: str) -> None:
event_id = db.create_custom_event(game, "NEED_ACTION")
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
client,
game,
listener_screen,
node_type="trigger",
trigger_event="evenement",
trigger_custom_event_id=event_id,
)
# Une AUTRE scène — la cascade doit atteindre TOUS les écrans, pas
# seulement celui qui a servi à déclencher la suppression.
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
client,
game,
dispatcher_screen,
node_type="action",
action_type="declencher_evenement",
target_custom_event_id=event_id,
)
resp = client.post(f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False)
resp = client.post(
f"/game/{game}/events/{event_id}/delete", data={"screen_id": listener_screen}, follow_redirects=False
)
assert resp.status_code == 302
assert db.get_custom_event(game, event_id) is None
@@ -106,7 +136,7 @@ def test_deleting_an_event_cascades_to_every_referencing_node_everywhere(client,
assert screens.get_screen(game, dispatcher_screen) is not None
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
def test_runtime_payload_exposes_both_nodes_for_the_client_js(client: FlaskClient, game: str) -> None:
"""templates/play.html::dispatchGameEvent()/findTriggerNode() scannent
gameData.flows (toutes les scènes ET tous les modèles à la fois) pour
retrouver un nœud par trigger_custom_event_id/target_custom_event_id —
@@ -118,14 +148,22 @@ def test_runtime_payload_exposes_both_nodes_for_the_client_js(client, game):
listener_screen = _create_screen(client, game, "Écran écouteur")
listener_node_id = _add_flow_node(
client, game, listener_screen,
node_type="trigger", trigger_event="evenement", trigger_custom_event_id=event_id,
client,
game,
listener_screen,
node_type="trigger",
trigger_event="evenement",
trigger_custom_event_id=event_id,
)
dispatcher_screen = _create_screen(client, game, "Écran déclencheur")
dispatcher_node_id = _add_flow_node(
client, game, dispatcher_screen,
node_type="action", action_type="declencher_evenement", target_custom_event_id=event_id,
client,
game,
dispatcher_screen,
node_type="action",
action_type="declencher_evenement",
target_custom_event_id=event_id,
)
payload = screens.full_game_payload(game)
+3 -3
View File
@@ -3,13 +3,13 @@ d'une exception ne doit plus bloquer les écritures suivantes avec
"database is locked" — voir _track_for_teardown()/_install_teardown_
safety_net() dans db/connection.py, qui ferme toute connexion encore
ouverte à la fin de la requête, que celle-ci ait réussi ou planté."""
import db
import screens
from core.flask_app import app
def test_connection_left_open_by_a_crashed_request_is_closed_at_teardown(game):
def test_connection_left_open_by_a_crashed_request_is_closed_at_teardown(game: str) -> None:
"""Simule exactement l'incident remonté : du code qui ouvre une
connexion en écriture puis plante avant de la fermer (comme
delete_element.py le ferait si conn.execute() levait une exception),
@@ -33,7 +33,7 @@ def test_connection_left_open_by_a_crashed_request_is_closed_at_teardown(game):
assert screen_id
def test_connection_is_not_tracked_outside_a_request_context(game):
def test_connection_is_not_tracked_outside_a_request_context(game: str) -> None:
"""Non-régression : un appel direct à db.connect() en dehors de tout
contexte Flask (scripts, tests) ne doit pas planter — has_app_context()
doit simplement renvoyer False et ne rien suivre."""
+23 -15
View File
@@ -1,25 +1,29 @@
"""Tests unitaires purs de la couche données (db/) — sans passer par Flask."""
from collections.abc import Callable
import db
from tests.conftest import not_none
def test_slugify_basic():
def test_slugify_basic() -> None:
assert db.slugify("Hello World") == "hello-world"
assert db.slugify("Île de Ré !!") == "le-de-r"
assert db.slugify("") == "jeu"
def test_table_name_for():
def test_table_name_for() -> None:
assert db.table_name_for("Personnage Principal") == "obj_personnage_principal"
def test_create_game_and_meta(tmp_game_slug_cleanup):
def test_create_game_and_meta(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug = db.create_game("Mon Jeu De Test")
tmp_game_slug_cleanup(slug)
meta = db.game_meta(slug)
assert meta["name"] == "Mon Jeu De Test"
def test_list_games_only_returns_the_given_owners_projects(tmp_game_slug_cleanup):
def test_list_games_only_returns_the_given_owners_projects(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""db.list_games(owner_folder) ne scanne plus jamais projects/ en
entier (voir routes/games/index.py — faille corrigée : un admin
voyait auparavant les projets de tous les comptes)."""
@@ -29,7 +33,7 @@ def test_list_games_only_returns_the_given_owners_projects(tmp_game_slug_cleanup
assert db.list_games("000000") == []
def test_create_game_deduplicates_slug(tmp_game_slug_cleanup):
def test_create_game_deduplicates_slug(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug1 = db.create_game("Doublon")
tmp_game_slug_cleanup(slug1)
slug2 = db.create_game("Doublon")
@@ -37,16 +41,19 @@ def test_create_game_deduplicates_slug(tmp_game_slug_cleanup):
assert slug1 != slug2
def test_definition_crud(tmp_game_slug_cleanup):
def test_definition_crud(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug = db.create_game("Objets Test")
tmp_game_slug_cleanup(slug)
definition_id = db.create_definition(
slug, "Personnage",
[{"name": "nom", "type": "texte", "required": True},
{"name": "pv", "type": "nombre_entier", "required": False}],
slug,
"Personnage",
[
{"name": "nom", "type": "texte", "required": True},
{"name": "pv", "type": "nombre_entier", "required": False},
],
)
definition = db.get_definition(slug, definition_id)
definition = not_none(db.get_definition(slug, definition_id))
assert definition["name"] == "Personnage"
assert {f["name"] for f in definition["fields"]} == {"nom", "pv"}
@@ -58,15 +65,15 @@ def test_definition_crud(tmp_game_slug_cleanup):
row_id = rows[0]["id"]
db.update_row(slug, definition, row_id, {"nom": "Aldric le Brave", "pv": "80"})
updated = db.get_row(slug, definition, row_id)
updated = not_none(db.get_row(slug, definition, row_id))
assert updated["nom"] == "Aldric le Brave"
assert updated["pv"] == 80
field_def = next(f for f in definition["fields"] if f["name"] == "pv")
db.update_row_field(slug, definition, row_id, field_def, 42)
assert db.get_row(slug, definition, row_id)["pv"] == 42
assert not_none(db.get_row(slug, definition, row_id))["pv"] == 42
# update_row_field ne doit toucher qu'à cette seule colonne
assert db.get_row(slug, definition, row_id)["nom"] == "Aldric le Brave"
assert not_none(db.get_row(slug, definition, row_id))["nom"] == "Aldric le Brave"
db.delete_row(slug, definition, row_id)
assert db.list_rows(slug, definition) == []
@@ -75,12 +82,13 @@ def test_definition_crud(tmp_game_slug_cleanup):
assert db.get_definition(slug, definition_id) is None
def test_relation_blocks_deletion_reference_check(tmp_game_slug_cleanup):
def test_relation_blocks_deletion_reference_check(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug = db.create_game("Relations Test")
tmp_game_slug_cleanup(slug)
lieu_id = db.create_definition(slug, "Lieu", [{"name": "nom", "type": "texte"}])
perso_id = db.create_definition(
slug, "Personnage",
slug,
"Personnage",
[{"name": "nom", "type": "texte"}, {"name": "lieu", "type": "relation", "relation_definition_id": lieu_id}],
)
refs = db.definitions_referencing(slug, lieu_id)
+83 -47
View File
@@ -6,37 +6,44 @@ static/js/play/dialogue-box-controller.js) — header/body/footer
stylisables. Et le nom d'un personnage ("ℹ️ Informations",
screens/scenes/set_scene_object_name.py), utilisé comme "qui parle" dans
l'éditeur de dialogue d'un déclencheur (screens/rendering/scene_object_names.py)."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_dialogue_box"):
def _create_jeu2d_game(tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_dialogue_box") -> Any:
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def test_add_dialogue_box_has_sensible_default_size(tmp_game_slug_cleanup):
def test_add_dialogue_box_has_sensible_default_size(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["kind"] == "dialogue_box"
assert obj["width"] == 320
assert obj["height"] == 150
def test_dialogue_box_default_style(tmp_game_slug_cleanup):
def test_dialogue_box_default_style(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
style = screens.resolve_dialogue_box_style(obj)
assert style == screens.DEFAULT_DIALOGUE_BOX_STYLE
def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup):
def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "dialogueBoxHeader" in html
assert "dialogueBoxBody" in html
@@ -51,38 +58,53 @@ def test_render_dialogue_box_has_header_body_footer(tmp_game_slug_cleanup):
assert 'data-dialogue-role="footer"' in html
def test_set_scene_object_dialogue_box_style_persists_and_sanitizes(tmp_game_slug_cleanup):
def test_set_scene_object_dialogue_box_style_persists_and_sanitizes(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
style = screens.set_scene_object_dialogue_box_style(slug, obj_id, {
"font_family": "Georgia", "font_size": 999, "font_weight": "bold",
"text_color": "#ff0000", "header_bg": "#000000", "unknown_key": "ignored",
})
style = not_none(
screens.set_scene_object_dialogue_box_style(
slug,
obj_id,
{
"font_family": "Georgia",
"font_size": 999,
"font_weight": "bold",
"text_color": "#ff0000",
"header_bg": "#000000",
"unknown_key": "ignored",
},
)
)
assert style["font_family"] == "Georgia"
assert style["font_size"] == 48 # bornée au maximum autorisé
assert style["font_weight"] == "bold"
assert style["text_color"] == "#ff0000"
assert style["header_bg"] == "#000000"
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_dialogue_box_style(obj) == style
def test_dialogue_box_route_persists_style(client, tmp_game_slug_cleanup):
def test_dialogue_box_route_persists_style(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/dialogue-box-style",
data='{"style": {"header_bg": "#123456"}}', content_type="application/json",
data='{"style": {"header_bg": "#123456"}}',
content_type="application/json",
)
assert resp.get_json()["style"]["header_bg"] == "#123456"
def test_dialogue_box_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/dialogue-box-style", data='{"style": {}}', content_type="application/json")
def test_dialogue_box_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/scene-objects/999999/dialogue-box-style", data='{"style": {}}', content_type="application/json"
)
assert resp.status_code == 404
def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanup):
def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="dialogue_box")
payload = screens.full_game_payload(slug)
@@ -93,13 +115,17 @@ def test_dialogue_box_excluded_from_collision_rules_payload(tmp_game_slug_cleanu
assert box["dialogue_box_style"] == screens.DEFAULT_DIALOGUE_BOX_STYLE
def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(tmp_game_slug_cleanup):
def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
rules = [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True},
}]
rules = [
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}, "mark_completed": True},
}
]
screens.set_scene_object_collision_rules(slug, obj_id, screens.sanitize_collision_rules(rules))
payload = screens.full_game_payload(slug)
assert payload["completable_dialogue_ids"] == ["d_1"]
@@ -107,38 +133,43 @@ def test_full_game_payload_exposes_completable_dialogue_ids_game_wide(tmp_game_s
# ---------- Nom d'un personnage ("ℹ️ Informations") ----------
def test_scene_object_name_is_none_by_default(tmp_game_slug_cleanup):
def test_scene_object_name_is_none_by_default(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["name"] is None
def test_set_scene_object_name_persists_and_trims(tmp_game_slug_cleanup):
def test_set_scene_object_name_persists_and_trims(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_name(slug, obj_id, " Le Sorcier ")
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
assert not_none(screens.get_scene_object(slug, obj_id))["name"] == "Le Sorcier"
def test_set_scene_object_name_blank_clears_it(tmp_game_slug_cleanup):
def test_set_scene_object_name_blank_clears_it(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
screens.set_scene_object_name(slug, obj_id, "Le Sorcier")
screens.set_scene_object_name(slug, obj_id, " ")
assert screens.get_scene_object(slug, obj_id)["name"] is None
assert not_none(screens.get_scene_object(slug, obj_id))["name"] is None
def test_scene_object_name_route_persists(client, tmp_game_slug_cleanup):
def test_scene_object_name_route_persists(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage")
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json"
)
assert resp.status_code == 200
assert screens.get_scene_object(slug, obj_id)["name"] == "Le Sorcier"
assert not_none(screens.get_scene_object(slug, obj_id))["name"] == "Le Sorcier"
def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_kind(tmp_game_slug_cleanup):
""""il peut sélectionner le nom du personnage qui parle" -> étendu à
def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_kind(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
""" "il peut sélectionner le nom du personnage qui parle" -> étendu à
N'IMPORTE QUEL objet de scène nommé (pas seulement un personnage),
voir screens/rendering/scene_object_names.py."""
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_object_names"))
@@ -148,7 +179,7 @@ def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_
screens.set_scene_object_name(slug, zed_id, "Zed le forgeron")
anna_id = screens.add_scene_object(slug, scene_b, kind="personnage")
screens.set_scene_object_name(slug, anna_id, "Anna")
unnamed_id = screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré
screens.add_scene_object(slug, scene_a, kind="personnage") # sans nom, ignoré
decor_id = screens.add_scene_object(slug, scene_a, kind="decor") # nommé -> INCLUS (pas réservé au personnage)
screens.set_scene_object_name(slug, decor_id, "Un coffre")
dialogue_box_id = screens.add_scene_object(slug, scene_a, kind="dialogue_box") # widget d'UI, jamais un "qui parle"
@@ -159,28 +190,29 @@ def test_list_named_scene_object_names_is_alphabetical_deduped_and_includes_any_
# ---------- Widgets "❓ Boîte à quiz" / "🏆 Score" ----------
def test_add_quiz_box_has_sensible_default_size(tmp_game_slug_cleanup):
def test_add_quiz_box_has_sensible_default_size(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["kind"] == "quiz_box"
assert obj["width"] == 340
assert obj["height"] == 240
def test_add_score_widget_has_sensible_default_size(tmp_game_slug_cleanup):
def test_add_score_widget_has_sensible_default_size(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="score_widget")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["kind"] == "score_widget"
assert obj["width"] == 140
assert obj["height"] == 50
def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup):
def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'data-quiz-role="header"' in html # nom de l'objet déclencheur, rempli en jeu
assert 'data-quiz-role="question"' in html # le texte de la question, dans le corps
@@ -189,25 +221,29 @@ def test_render_quiz_box_has_header_and_choices(tmp_game_slug_cleanup):
assert "quizBoxFooter" not in html # pas de pied, une question se résout au clic
def test_render_score_widget_shows_zero_by_default(tmp_game_slug_cleanup):
def test_render_score_widget_shows_zero_by_default(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="score_widget")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'data-score-role="value"' in html
assert f'data-object-id="{obj_id}"' in html
def test_quiz_box_and_score_widget_share_the_same_style_system_as_dialogue_box(tmp_game_slug_cleanup):
def test_quiz_box_and_score_widget_share_the_same_style_system_as_dialogue_box(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
quiz_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
style = screens.set_scene_object_dialogue_box_style(slug, quiz_id, {"header_bg": "#abcdef"})
style = not_none(screens.set_scene_object_dialogue_box_style(slug, quiz_id, {"header_bg": "#abcdef"}))
assert style["header_bg"] == "#abcdef"
obj = screens.get_scene_object(slug, quiz_id)
obj = not_none(screens.get_scene_object(slug, quiz_id))
assert screens.resolve_dialogue_box_style(obj)["header_bg"] == "#abcdef"
def test_quiz_box_and_score_widget_excluded_from_collision_rules_payload(tmp_game_slug_cleanup):
def test_quiz_box_and_score_widget_excluded_from_collision_rules_payload(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.add_scene_object(slug, screen_id, kind="score_widget")
+21 -11
View File
@@ -2,31 +2,41 @@
(demande explicite : "ajouter une réplique audio"), voix off jouée en
même temps que la bulle de réplique s'affiche (voir static/js/play/
dialogue-box-controller.js::forgeRenderDialogueBoxLine)."""
import db
def test_sanitize_dialogue_lines_keeps_a_valid_audio_url_on_a_dialogue_line():
lines = db.sanitize_dialogue_lines([
{"speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"},
])
def test_sanitize_dialogue_lines_keeps_a_valid_audio_url_on_a_dialogue_line() -> None:
lines = db.sanitize_dialogue_lines(
[
{"speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"},
]
)
assert lines == [{"type": "dialogue", "speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"}]
def test_sanitize_dialogue_lines_omits_audio_url_when_absent():
def test_sanitize_dialogue_lines_omits_audio_url_when_absent() -> None:
lines = db.sanitize_dialogue_lines([{"speaker": "Marc", "text": "Bonjour !"}])
assert "audio_url" not in lines[0]
def test_sanitize_dialogue_lines_omits_a_blank_audio_url():
def test_sanitize_dialogue_lines_omits_a_blank_audio_url() -> None:
lines = db.sanitize_dialogue_lines([{"speaker": "Marc", "text": "Bonjour !", "audio_url": " "}])
assert "audio_url" not in lines[0]
def test_sanitize_dialogue_lines_ignores_audio_url_on_a_question_line():
def test_sanitize_dialogue_lines_ignores_audio_url_on_a_question_line() -> None:
"""L'audio n'a de sens que pour une RÉPLIQUE — une question à choix
("❓ Question") reste inchangée, voir QUESTION_REWARD_TYPES."""
lines = db.sanitize_dialogue_lines([{
"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1,
"audio_url": "/assets/1/question.mp3",
}])
lines = db.sanitize_dialogue_lines(
[
{
"type": "question",
"text": "2+2 ?",
"choices": ["3", "4"],
"correct_index": 1,
"audio_url": "/assets/1/question.mp3",
}
]
)
assert "audio_url" not in lines[0]
+4 -2
View File
@@ -4,16 +4,18 @@
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
import pytest
from dotenv import load_dotenv
import core.flask_app
def test_load_dotenv_never_overrides_an_already_set_variable(monkeypatch):
def test_load_dotenv_never_overrides_an_already_set_variable(monkeypatch: pytest.MonkeyPatch) -> None:
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")
@@ -25,7 +27,7 @@ def test_load_dotenv_never_overrides_an_already_set_variable(monkeypatch):
os.remove(env_path)
def test_core_flask_app_does_not_pass_override_true_to_load_dotenv():
def test_core_flask_app_does_not_pass_override_true_to_load_dotenv() -> None:
"""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)."""
+107 -46
View File
@@ -6,15 +6,19 @@ vérifient l'assemblage (imsmanifest.xml, index.html en FORGE_OFFLINE,
JS hors ligne présent), jamais un vrai import dans un LMS réel (voir le
plan — validation ADL SCORM Cloud/Moodle hors du champ de la suite
automatisée)."""
import re
import zipfile
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
from tests.test_auth import anon_client # noqa: F401
def test_export_scorm_route_returns_a_zip_attachment(client, game):
def test_export_scorm_route_returns_a_zip_attachment(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
@@ -22,15 +26,19 @@ def test_export_scorm_route_returns_a_zip_attachment(client, game):
assert resp.data[:2] == b"PK"
def test_export_scorm_zip_contains_the_expected_files(client, game):
def test_export_scorm_zip_contains_the_expected_files(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
names = set(zf.namelist())
for expected in [
"imsmanifest.xml", "index.html",
"static/style.css", "static/csrf_fetch.js",
"static/js/play/actions.js", "static/js/play/bindings.js",
"imsmanifest.xml",
"index.html",
"static/style.css",
"static/csrf_fetch.js",
"static/js/play/actions.js",
"static/js/play/bindings.js",
"static/js/play/offline/full-payload.js",
"static/js/play/offline/apply-actions.js",
"static/js/play/offline/scorm-api.js",
@@ -40,9 +48,10 @@ def test_export_scorm_zip_contains_the_expected_files(client, game):
assert not any("__tests__" in n for n in names)
def test_export_scorm_index_html_is_offline_and_self_contained(client, game):
def test_export_scorm_index_html_is_offline_and_self_contained(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_OFFLINE = true" in html
@@ -53,18 +62,20 @@ def test_export_scorm_index_html_is_offline_and_self_contained(client, game):
assert "let gameData = " in html
def test_export_scorm_manifest_lists_index_html_as_the_sco(client, game):
def test_export_scorm_manifest_lists_index_html_as_the_sco(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
manifest = zf.read("imsmanifest.xml").decode("utf-8")
assert 'href="index.html"' in manifest
assert "adlcp:scormtype=\"sco\"" in manifest
assert 'adlcp:scormtype="sco"' in manifest
def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(client, game):
def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_XAPI_CONFIG = null;" in html
@@ -72,10 +83,11 @@ def test_export_scorm_index_html_has_no_xapi_config_when_unconfigured(client, ga
assert 'src="static/js/play/offline/xapi-client.js"' in html
def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game):
def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client: FlaskClient, game: str) -> None:
db.set_xapi_settings(game, "https://lrs.example.com/xapi/", "monlogin", "monmotdepasse")
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_XAPI_CONFIG = {" in html
@@ -85,9 +97,10 @@ def test_export_scorm_index_html_embeds_the_xapi_config_when_set(client, game):
assert 'src="static/js/play/offline/xapi-client.js"' in html
def test_export_scorm_index_html_embeds_the_success_threshold(client, game):
def test_export_scorm_index_html_embeds_the_success_threshold(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_SUCCESS_THRESHOLD_PERCENT = 70;" in html
@@ -99,9 +112,10 @@ def test_export_scorm_index_html_embeds_the_success_threshold(client, game):
assert "window.FORGE_SUCCESS_THRESHOLD_PERCENT = 40;" in html
def test_export_scorm_defaults_to_scorm_1_2_manifest_and_wrapper(client, game):
def test_export_scorm_defaults_to_scorm_1_2_manifest_and_wrapper(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
manifest = zf.read("imsmanifest.xml").decode("utf-8")
html = zf.read("index.html").decode("utf-8")
@@ -111,10 +125,11 @@ def test_export_scorm_defaults_to_scorm_1_2_manifest_and_wrapper(client, game):
assert 'src="static/js/play/offline/scorm2004-api.js"' not in html
def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client, game):
def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client: FlaskClient, game: str) -> None:
db.set_scorm_version(game, "2004")
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
names = set(zf.namelist())
manifest = zf.read("imsmanifest.xml").decode("utf-8")
@@ -126,11 +141,12 @@ def test_export_scorm_2004_uses_the_2004_manifest_and_wrapper(client, game):
assert 'src="static/js/play/offline/scorm-api.js"' not in html
def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
def test_export_scorm_index_html_embeds_the_max_quiz_score(client: FlaskClient, game: str) -> None:
"""Sans déclencheur/question : max=0 (SCORM 1.2/2004 n'ont alors rien à
barémer, voir static/js/play/offline/scorm-api.js)."""
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_MAX_QUIZ_SCORE = 0;" in html
@@ -138,18 +154,37 @@ def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
# Les dialogues vivent dans les règles de collision d'un objet de
# scène (voir screens/rendering/collision_rules.py::"dialogue".dialogue).
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
rules = [{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": [
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
]},
},
}]
resp = client.post(
f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
)
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
rules = [
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {
"id": "d_1",
"lines": [
{
"type": "question",
"text": "1+1 ?",
"choices": ["1", "2"],
"correct_index": 1,
"reward_amount": 5,
},
{
"type": "question",
"text": "2+2 ?",
"choices": ["3", "4"],
"correct_index": 1,
"reward_amount": 7,
},
],
},
},
}
]
screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules))
resp = client.post(f"/game/{game}/export-scorm")
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
@@ -157,44 +192,70 @@ def test_export_scorm_index_html_embeds_the_max_quiz_score(client, game):
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
def test_export_scorm_index_html_embeds_the_max_quiz_score_across_a_then_chain(client, game):
def test_export_scorm_index_html_embeds_the_max_quiz_score_across_a_then_chain(client: FlaskClient, game: str) -> None:
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
lui ajouter des actions") : un dialogue -> then variable -> then
dialogue — les DEUX dialogues de la chaîne comptent pour le score max,
la feuille "variable" au milieu n'en interrompt pas la collecte."""
screen_id = screens.create_screen(game, "Scène 1", kind="jeu_2d")
resp = client.post(f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
rules = [{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": [
{"type": "question", "text": "1+1 ?", "choices": ["1", "2"], "correct_index": 1, "reward_amount": 5},
]},
"then": {
"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1",
resp = client.post(
f"/game/{game}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
)
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
rules = [
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {
"id": "d_1",
"lines": [
{
"type": "question",
"text": "1+1 ?",
"choices": ["1", "2"],
"correct_index": 1,
"reward_amount": 5,
},
],
},
"then": {
"type": "dialogue",
"dialogue": {"id": "d_2", "lines": [
{"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1, "reward_amount": 7},
]},
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
"then": {
"type": "dialogue",
"dialogue": {
"id": "d_2",
"lines": [
{
"type": "question",
"text": "2+2 ?",
"choices": ["3", "4"],
"correct_index": 1,
"reward_amount": 7,
},
],
},
},
},
},
},
}]
}
]
screens.set_scene_object_collision_rules(game, obj_id, screens.sanitize_collision_rules(rules))
resp = client.post(f"/game/{game}/export-scorm")
import io
with zipfile.ZipFile(io.BytesIO(resp.data)) as zf:
html = zf.read("index.html").decode("utf-8")
assert "window.FORGE_MAX_QUIZ_SCORE = 12;" in html
def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client):
def test_export_scorm_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
_register(anon_client, "scormexportisolation@example.com")
_confirm_2fa(anon_client)
+13 -8
View File
@@ -1,17 +1,22 @@
"""Tests de l'éditeur de flow à nœuds (déclencheur / condition / action)."""
import json
import re
from typing import Any
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import screens
from conftest import play_js_bundle
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def test_flow_node_and_edge_crud(client, game):
def test_flow_node_and_edge_crud(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
element_id = screens.add_scene_object(game, screen_id)
@@ -42,7 +47,7 @@ def test_flow_node_and_edge_crud(client, game):
assert str(trigger_node["id"]) in html
def test_multiple_actions_from_one_trigger(client, game):
def test_multiple_actions_from_one_trigger(client: FlaskClient, game: str) -> None:
"""Régression : un déclencheur doit pouvoir avoir PLUSIEURS arêtes
sortantes (plusieurs actions exécutées au même clic)."""
screen_id = _create_screen(client, game)
@@ -77,7 +82,7 @@ def test_multiple_actions_from_one_trigger(client, game):
assert len(from_trigger) == 2
def test_play_page_exposes_row_bindings_helpers(client, game):
def test_play_page_exposes_row_bindings_helpers(client: FlaskClient, game: str) -> None:
"""Le runtime jouable doit embarquer la logique côté client capable de
résoudre les {{champ}} de la ligne ouverte (voir README, section
"Cliquer une ligne de Répéteur") — vérifie que le JS livré au joueur
@@ -88,7 +93,7 @@ def test_play_page_exposes_row_bindings_helpers(client, game):
assert "'ouvrir_ligne'" in js
def test_affichage_trigger_does_not_require_element(client, game):
def test_affichage_trigger_does_not_require_element(client: FlaskClient, game: str) -> None:
"""Régression 1.2 : le nouveau déclencheur "affichage" doit pouvoir être
créé SANS élément cible (il concerne l'écran entier), contrairement au
déclencheur "clic" — voir screens/flow/constants.py."""
@@ -104,7 +109,7 @@ def test_affichage_trigger_does_not_require_element(client, game):
assert node["trigger_element_id"] is None
def test_play_page_exposes_screen_show_trigger_helper(client, game):
def test_play_page_exposes_screen_show_trigger_helper(client: FlaskClient, game: str) -> None:
"""Le runtime jouable doit exécuter les nœuds "affichage" à chaque fois
qu'un écran est montré (premier affichage, retour en arrière, données
modifiées) — voir README, section correspondante."""
+48 -24
View File
@@ -4,19 +4,23 @@ milliers de nœuds (voir templates/screen_edit.html, onglet "🧩 Blocs de
logique"). Un bloc est un automate FERMÉ : aucune arête possible entre deux
nœuds de blocs différents (toute communication inter-blocs passe par les
événements personnalisés, voir tests/test_custom_events.py)."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -26,7 +30,9 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def _add_flow_edge(client, slug, screen_id, from_node_id, to_node_id, from_port="out"):
def _add_flow_edge(
client: FlaskClient, slug: str, screen_id: int, from_node_id: int, to_node_id: int, from_port: str = "out"
) -> Any:
return client.post(
f"/game/{slug}/screens/{screen_id}/flow/edges/add",
data=json.dumps({"from_node_id": from_node_id, "from_port": from_port, "to_node_id": to_node_id}),
@@ -34,18 +40,22 @@ def _add_flow_edge(client, slug, screen_id, from_node_id, to_node_id, from_port=
)
def test_create_and_list_flow_blocks(client, game):
def test_create_and_list_flow_blocks(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/new", data={
"name": "Ouvrir la porte", "description": "Ouvre la porte au clic.",
})
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/blocks/new",
data={
"name": "Ouvrir la porte",
"description": "Ouvre la porte au clic.",
},
)
assert resp.status_code == 302
blocks = screens.list_flow_blocks(game, screen_id)
assert len(blocks) == 1
assert blocks[0]["name"] == "Ouvrir la porte"
def test_orphan_nodes_are_migrated_into_a_default_block(client, game):
def test_orphan_nodes_are_migrated_into_a_default_block(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
# Nœud posé "avant" l'existence des blocs (aucun block_id transmis) —
# doit être rattaché à un "Bloc principal" auto-créé au premier
@@ -55,7 +65,7 @@ def test_orphan_nodes_are_migrated_into_a_default_block(client, game):
blocks = screens.list_flow_blocks(game, screen_id)
assert len(blocks) == 1
assert blocks[0]["name"] == "Bloc principal"
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["block_id"] == blocks[0]["id"]
# Idempotent : un second appel ne crée pas un second "Bloc principal".
@@ -63,62 +73,76 @@ def test_orphan_nodes_are_migrated_into_a_default_block(client, game):
assert len(blocks_again) == 1
def test_node_created_with_a_block_id_is_not_touched_by_migration(client, game):
def test_node_created_with_a_block_id_is_not_touched_by_migration(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
block_id = screens.create_flow_block(game, screen_id, "Mon bloc")
node_id = _add_flow_node(
client, game, screen_id,
node_type="trigger", trigger_event="affichage", block_id=block_id,
client,
game,
screen_id,
node_type="trigger",
trigger_event="affichage",
block_id=block_id,
)
blocks = screens.list_flow_blocks(game, screen_id)
assert len(blocks) == 1
assert blocks[0]["id"] == block_id
assert screens.get_flow_node(game, node_id)["block_id"] == block_id
assert not_none(screens.get_flow_node(game, node_id))["block_id"] == block_id
def test_edge_between_two_different_blocks_is_rejected(client, game):
def test_edge_between_two_different_blocks_is_rejected(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
block_a = screens.create_flow_block(game, screen_id, "Bloc A")
block_b = screens.create_flow_block(game, screen_id, "Bloc B")
node_a = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a)
node_b = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_b)
node_b = _add_flow_node(
client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_b
)
resp = _add_flow_edge(client, game, screen_id, node_a, node_b)
assert resp.status_code == 400
# Une arête entre deux nœuds du MÊME bloc, elle, doit réussir.
node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a)
node_a2 = _add_flow_node(
client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a
)
resp_ok = _add_flow_edge(client, game, screen_id, node_a, node_a2)
assert resp_ok.status_code == 200
def test_deleting_a_block_cascades_only_its_own_nodes_and_edges(client, game):
def test_deleting_a_block_cascades_only_its_own_nodes_and_edges(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
block_a = screens.create_flow_block(game, screen_id, "Bloc A")
block_b = screens.create_flow_block(game, screen_id, "Bloc B")
node_a1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_a)
node_a2 = _add_flow_node(client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a)
node_a2 = _add_flow_node(
client, game, screen_id, node_type="action", action_type="attendre", data_value="1", block_id=block_a
)
edge_resp = _add_flow_edge(client, game, screen_id, node_a1, node_a2)
assert edge_resp.status_code == 200
edge_id = edge_resp.get_json()["id"]
node_b1 = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="affichage", block_id=block_b)
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/blocks/{block_a}/delete", data={}, follow_redirects=False)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/blocks/{block_a}/delete", data={}, follow_redirects=False
)
assert resp.status_code == 302
assert screens.get_flow_block(game, block_a) is None
assert screens.get_flow_node(game, node_a1) is None
assert screens.get_flow_node(game, node_a2) is None
assert screens.list_flow_edges(game, screen_id) == [] or all(e["id"] != edge_id for e in screens.list_flow_edges(game, screen_id))
assert screens.list_flow_edges(game, screen_id) == [] or all(
e["id"] != edge_id for e in screens.list_flow_edges(game, screen_id)
)
# Le bloc B et son nœud, eux, ne sont pas touchés.
assert screens.get_flow_block(game, block_b) is not None
assert screens.get_flow_node(game, node_b1) is not None
def test_screen_edit_page_loads_after_orphan_node_migration(client, game):
def test_screen_edit_page_loads_after_orphan_node_migration(client: FlaskClient, game: str) -> None:
"""routes/screens/screen_edit.py doit lancer list_flow_blocks() (qui
migre les nœuds orphelins vers "Bloc principal") AVANT list_flow_nodes()
— sinon la page affiche 0 nœud pour "Bloc principal" au tout premier
@@ -133,4 +157,4 @@ def test_screen_edit_page_loads_after_orphan_node_migration(client, game):
blocks = screens.list_flow_blocks(game, screen_id)
assert len(blocks) == 1
assert blocks[0]["name"] == "Bloc principal"
assert screens.get_flow_node(game, node_id)["block_id"] == blocks[0]["id"]
assert not_none(screens.get_flow_node(game, node_id))["block_id"] == blocks[0]["id"]
+63 -34
View File
@@ -4,35 +4,46 @@ Répéteur sur laquelle on vient de cliquer" plutôt qu'une ligne fixe -
indispensable quand le déclencheur "Au clic" est posé sur un Répéteur,
puisque chaque ligne exécute le même graphe et la ligne cliquée n'est
jamais connue à l'avance dans l'éditeur."""
import json
import re
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _create_email_object(client, slug):
def _create_email_object(client: FlaskClient, slug: str) -> Any:
def_id = db.create_definition(slug, "Email", [{"name": "is_opened", "type": "booleen"}])
definition = db.get_definition(slug, def_id)
definition = not_none(db.get_definition(slug, def_id))
db.insert_row(slug, definition, {})
db.insert_row(slug, definition, {})
return def_id
def test_modifier_donnee_targeting_clicked_row_updates_only_that_row(client, game):
def test_modifier_donnee_targeting_clicked_row_updates_only_that_row(client: FlaskClient, game: str) -> None:
def_id = _create_email_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": -1, "target_field": "is_opened",
"data_operation": "definir_bool_vrai", "data_value": "",
}),
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": -1,
"target_field": "is_opened",
"data_operation": "definir_bool_vrai",
"data_value": "",
}
),
content_type="application/json",
)
node = resp.get_json()
@@ -45,54 +56,72 @@ def test_modifier_donnee_targeting_clicked_row_updates_only_that_row(client, gam
)
assert resp.get_json() == {"ok": True}
definition = db.get_definition(game, def_id)
row1 = db.get_row(game, definition, 1)
row2 = db.get_row(game, definition, 2)
definition = not_none(db.get_definition(game, def_id))
row1 = not_none(db.get_row(game, definition, 1))
row2 = not_none(db.get_row(game, definition, 2))
assert not row1["is_opened"]
assert row2["is_opened"]
def test_modifier_donnee_targeting_clicked_row_without_a_click_is_a_noop(client, game):
def test_modifier_donnee_targeting_clicked_row_without_a_click_is_a_noop(client: FlaskClient, game: str) -> None:
"""Pas de ligne cliquée transmise (ex. déclenché autrement qu'un vrai
clic sur une ligne de Répéteur) -> ne doit rien modifier, pas planter."""
def_id = _create_email_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": -1, "target_field": "is_opened",
"data_operation": "definir_bool_vrai", "data_value": "",
}),
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": -1,
"target_field": "is_opened",
"data_operation": "definir_bool_vrai",
"data_value": "",
}
),
content_type="application/json",
)
node = resp.get_json()
resp = client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data", data=json.dumps({}), content_type="application/json")
resp = client.post(
f"/game/{game}/flow/nodes/{node['id']}/run-data", data=json.dumps({}), content_type="application/json"
)
assert resp.get_json() == {"ok": False}
definition = db.get_definition(game, def_id)
assert not db.get_row(game, definition, 1)["is_opened"]
assert not db.get_row(game, definition, 2)["is_opened"]
definition = not_none(db.get_definition(game, def_id))
assert not not_none(db.get_row(game, definition, 1))["is_opened"]
assert not not_none(db.get_row(game, definition, 2))["is_opened"]
def test_ordinary_fixed_row_target_is_unaffected(client, game):
def test_ordinary_fixed_row_target_is_unaffected(client: FlaskClient, game: str) -> None:
"""Non-régression : une action visant une ligne FIXE (pas -1) continue
d'ignorer tout clicked_row_id transmis."""
def_id = _create_email_object(client, game)
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "modifier_donnee",
"target_definition_id": def_id, "target_row_id": 1, "target_field": "is_opened",
"data_operation": "definir_bool_vrai", "data_value": "",
}),
data=json.dumps(
{
"node_type": "action",
"action_type": "modifier_donnee",
"target_definition_id": def_id,
"target_row_id": 1,
"target_field": "is_opened",
"data_operation": "definir_bool_vrai",
"data_value": "",
}
),
content_type="application/json",
)
node = resp.get_json()
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data", data=json.dumps({"clicked_row_id": 2}), content_type="application/json")
client.post(
f"/game/{game}/flow/nodes/{node['id']}/run-data",
data=json.dumps({"clicked_row_id": 2}),
content_type="application/json",
)
definition = db.get_definition(game, def_id)
assert db.get_row(game, definition, 1)["is_opened"]
assert not db.get_row(game, definition, 2)["is_opened"]
definition = not_none(db.get_definition(game, def_id))
assert not_none(db.get_row(game, definition, 1))["is_opened"]
assert not not_none(db.get_row(game, definition, 2))["is_opened"]
+14 -8
View File
@@ -1,23 +1,29 @@
"""Type d'écran (voir screens/screens_repo/ensure_schema.py) : un seul
moteur d'écran désormais, "jeu_2d" (éditeur de scène, objets en pixels) —
le type "document" (éditeur générique) a été retiré."""
from collections.abc import Callable
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def test_default_screen_kind_is_jeu_2d(client, game):
def test_default_screen_kind_is_jeu_2d(client: FlaskClient, game: str) -> None:
screen_id = screens.create_screen(game, "Accueil")
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
assert not_none(screens.get_screen(game, screen_id))["kind"] == "jeu_2d"
def test_create_screen_with_explicit_jeu_2d_kind(tmp_game_slug_cleanup):
def test_create_screen_with_explicit_jeu_2d_kind(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug = tmp_game_slug_cleanup(db.create_game("pytest_jeu2d_test"))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
screen = screens.get_screen(slug, screen_id)
screen = not_none(screens.get_screen(slug, screen_id))
assert screen["kind"] == "jeu_2d"
def test_screens_new_route_always_creates_jeu_2d_screens(client, game):
def test_screens_new_route_always_creates_jeu_2d_screens(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/screens/new",
data={"name": "pytest_route_jeu2d"},
@@ -25,10 +31,10 @@ def test_screens_new_route_always_creates_jeu_2d_screens(client, game):
)
assert resp.status_code == 302
screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2])
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
assert not_none(screens.get_screen(game, screen_id))["kind"] == "jeu_2d"
def test_screens_new_route_ignores_any_kind_sent_by_the_client(client, game):
def test_screens_new_route_ignores_any_kind_sent_by_the_client(client: FlaskClient, game: str) -> None:
"""Il n'y a plus de sélecteur de type — un "kind" envoyé malgré tout
(formulaire personnalisé, requête manuelle) est ignoré, jamais lu."""
resp = client.post(
@@ -38,4 +44,4 @@ def test_screens_new_route_ignores_any_kind_sent_by_the_client(client, game):
)
assert resp.status_code == 302
screen_id = int(resp.headers["Location"].rstrip("/").split("/")[-2])
assert screens.get_screen(game, screen_id)["kind"] == "jeu_2d"
assert not_none(screens.get_screen(game, screen_id))["kind"] == "jeu_2d"
+28 -13
View File
@@ -4,17 +4,22 @@ pour le même patron) sur les fonctions déjà existantes de db/global_vars/
(list/create/update/delete). Le nom est la clé stable lue par
static/js/play/conditions.js::readVariableValue — jamais modifiable après
création (voir db/global_vars/update_global_variable.py)."""
import re
from typing import Any
from flask.testing import FlaskClient
import db
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def test_create_global_var_route_persists_and_redirects_to_the_variables_tab(client, game):
def test_create_global_var_route_persists_and_redirects_to_the_variables_tab(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/variables/new",
@@ -29,16 +34,19 @@ def test_create_global_var_route_persists_and_redirects_to_the_variables_tab(cli
assert variables[0]["value"] == "0"
def test_create_global_var_is_idempotent_by_name(client, game):
def test_create_global_var_is_idempotent_by_name(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "screen_id": screen_id})
assert len(db.list_global_variables(game)) == 1
def test_global_var_edit_route_updates_type_and_value(client, game):
def test_global_var_edit_route_updates_type_and_value(client: FlaskClient, game: str) -> None:
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})
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(
@@ -54,7 +62,7 @@ def test_global_var_edit_route_updates_type_and_value(client, game):
assert variable["name"] == "score_total" # le nom ne change jamais
def test_global_var_delete_route_removes_it(client, game):
def test_global_var_delete_route_removes_it(client: FlaskClient, game: str) -> None:
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"]
@@ -65,9 +73,12 @@ def test_global_var_delete_route_removes_it(client, game):
assert db.list_global_variables(game) == []
def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, game):
def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
client.post(f"/game/{game}/variables/new", data={"name": "score_total", "type": "nombre_entier", "default_value": "5", "screen_id": screen_id})
client.post(
f"/game/{game}/variables/new",
data={"name": "score_total", "type": "nombre_entier", "default_value": "5", "screen_id": screen_id},
)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "score_total" in html
@@ -78,7 +89,8 @@ def test_variables_tab_lists_existing_variables_in_the_scene_edit_page(client, g
# 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):
def test_create_global_var_ajax_returns_the_row_html_without_redirecting(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/variables/new",
@@ -92,9 +104,12 @@ def test_create_global_var_ajax_returns_the_row_html_without_redirecting(client,
assert 'data-var-row="' in body["row_html"]
def test_global_var_edit_ajax_returns_ok_without_redirecting(client, game):
def test_global_var_edit_ajax_returns_ok_without_redirecting(client: FlaskClient, game: str) -> None:
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})
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(
@@ -107,7 +122,7 @@ def test_global_var_edit_ajax_returns_ok_without_redirecting(client, game):
assert db.list_global_variables(game)[0]["value"] == "beaucoup"
def test_global_var_delete_ajax_returns_ok_without_redirecting(client, game):
def test_global_var_delete_ajax_returns_ok_without_redirecting(client: FlaskClient, game: str) -> None:
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"]
+36 -12
View File
@@ -4,17 +4,25 @@ 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."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_ia_chat"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_ia_chat"
) -> 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_create_and_list_conversations(client, tmp_game_slug_cleanup):
def test_create_and_list_conversations(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
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
@@ -27,7 +35,9 @@ def test_create_and_list_conversations(client, tmp_game_slug_cleanup):
assert conversation["id"] in ids
def test_conversations_are_isolated_per_screen(client, tmp_game_slug_cleanup):
def test_conversations_are_isolated_per_screen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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")
@@ -36,7 +46,9 @@ def test_conversations_are_isolated_per_screen(client, tmp_game_slug_cleanup):
assert resp.get_json() == []
def test_delete_conversation_removes_it_from_the_list(client, tmp_game_slug_cleanup):
def test_delete_conversation_removes_it_from_the_list(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
@@ -47,12 +59,14 @@ def test_delete_conversation_removes_it_from_the_list(client, tmp_game_slug_clea
assert all(c["id"] != conversation["id"] for c in remaining)
def test_delete_unknown_conversation_404s(client, game):
def test_delete_unknown_conversation_404s(client: FlaskClient, game: str) -> None:
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):
def test_send_message_persists_it_and_a_graceful_error_reply(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
@@ -67,19 +81,23 @@ def test_send_message_persists_it_and_a_graceful_error_reply(client, tmp_game_sl
assert "Configuration IA incomplète" in body["assistant_message"]["content"]
def test_send_message_rejects_an_empty_message(client, tmp_game_slug_cleanup):
def test_send_message_rejects_an_empty_message(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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):
def test_send_message_404s_for_an_unknown_conversation(client: FlaskClient, game: str) -> None:
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):
def test_messages_history_is_persisted_and_ordered(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
conversation = client.post(f"/game/{slug}/screens/{screen_id}/ia-conversations").get_json()
@@ -93,7 +111,9 @@ def test_messages_history_is_persisted_and_ordered(client, tmp_game_slug_cleanup
assert user_contents == ["premier", "second"]
def test_messages_are_isolated_per_conversation(client, tmp_game_slug_cleanup):
def test_messages_are_isolated_per_conversation(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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()
@@ -104,11 +124,15 @@ def test_messages_are_isolated_per_conversation(client, tmp_game_slug_cleanup):
assert resp.get_json() == []
def test_first_user_message_renames_the_conversation(client, tmp_game_slug_cleanup):
def test_first_user_message_renames_the_conversation(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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"})
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"])
+24 -13
View File
@@ -3,20 +3,24 @@
graphe de logique côté client, le comportement RÉEL d'un vrai keydown/
setInterval n'est pas testable sans navigateur (voir play_js_bundle() —
on vérifie que la logique existe dans le JS livré, pas qu'elle s'exécute)."""
import json
import re
from typing import Any
from conftest import play_js_bundle
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -26,37 +30,44 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def test_keyboard_trigger_node_persists_key(client, game):
def test_keyboard_trigger_node_persists_key(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="clavier", trigger_key="ArrowUp")
node = screens.get_flow_node(game, node_id)
node_id = _add_flow_node(
client, game, screen_id, node_type="trigger", trigger_event="clavier", trigger_key="ArrowUp"
)
node = not_none(screens.get_flow_node(game, node_id))
assert node["trigger_event"] == "clavier"
assert node["trigger_key"] == "ArrowUp"
assert node["trigger_element_id"] is None
def test_keyboard_trigger_persists_space_key(client, game):
def test_keyboard_trigger_persists_space_key(client: FlaskClient, game: str) -> None:
"""La barre d'espace (ev.key === ' ', un seul caractère espace) ne doit
PAS être traitée comme une valeur "vide" par add_flow_node.py (qui
filtre None/"" — " " n'est ni l'un ni l'autre) : une touche très
probablement utilisée ("Espace pour sauter") doit bien se persister."""
screen_id = _create_screen(client, game)
node_id = _add_flow_node(client, game, screen_id, node_type="trigger", trigger_event="clavier", trigger_key=" ")
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["trigger_key"] == " "
def test_timer_trigger_node_persists_interval(client, game):
def test_timer_trigger_node_persists_interval(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="trigger", trigger_event="minuteur", trigger_interval_ms=500,
client,
game,
screen_id,
node_type="trigger",
trigger_event="minuteur",
trigger_interval_ms=500,
)
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["trigger_event"] == "minuteur"
assert node["trigger_interval_ms"] == 500
def test_play_page_exposes_keyboard_and_timer_trigger_runtime(client, game):
def test_play_page_exposes_keyboard_and_timer_trigger_runtime(client: FlaskClient, game: str) -> None:
js = play_js_bundle()
assert "bindKeyboardTriggers" in js
assert "'clavier'" in js
+24 -16
View File
@@ -4,16 +4,19 @@ quoi que ce soit — désormais un seul type, "rpg" (jeu RPG 2D), les types
Quiz/Embranchement/"Créer mon jeu de A à Z" ayant été retirés avec le
moteur d'écran "document". Réutilise les fixtures de test_auth.py
(inscription/2FA)."""
import os
from flask.testing import FlaskClient
import auth
import db
import screens
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401
from tests.conftest import not_none
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register, anon_client # noqa: F401
def test_account_without_a_project_is_redirected_to_onboarding(anon_client):
def test_account_without_a_project_is_redirected_to_onboarding(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "needsonboarding@example.com")
_confirm_2fa(anon_client)
try:
@@ -28,13 +31,13 @@ def test_account_without_a_project_is_redirected_to_onboarding(anon_client):
_cleanup_project("needsonboarding@example.com")
def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_client):
def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "rpguser@example.com")
_confirm_2fa(anon_client)
try:
resp = _complete_onboarding(anon_client, name="Mon RPG", onboarding_type="rpg")
assert resp.status_code == 302
slug = auth.get_user_by_email("rpguser@example.com")["project_slug"]
slug = not_none(auth.get_user_by_email("rpguser@example.com"))["project_slug"]
assert resp.headers["Location"] == f"/game/{slug}"
assert db.get_onboarding_type(slug) == "rpg"
screen_list = screens.list_screens(slug)
@@ -48,7 +51,9 @@ def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_
# "+ Ajouter un écran" ne propose pas de choix de type — imposé
# jeu_2d par le projet, même en tentant de forcer "document" via
# un POST direct (défense en profondeur, pas seulement l'UI).
anon_client.post(f"/game/{slug}/screens/new", data={"name": "Scène 2", "kind": "document"}, follow_redirects=False)
anon_client.post(
f"/game/{slug}/screens/new", data={"name": "Scène 2", "kind": "document"}, follow_redirects=False
)
new_screen_list = screens.list_screens(slug)
assert len(new_screen_list) == 2
assert new_screen_list[1]["kind"] == "jeu_2d"
@@ -59,18 +64,18 @@ def test_rpg_dashboard_shows_scene_preview_and_forces_jeu2d_on_new_screens(anon_
_cleanup_project("rpguser@example.com")
def test_onboarding_rejects_unknown_type(anon_client):
def test_onboarding_rejects_unknown_type(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "badtype@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/onboarding", data={"onboarding_type": "n_importe_quoi", "name": "Test"})
assert resp.status_code == 200
assert auth.get_user_by_email("badtype@example.com")["project_slug"] is None
assert not_none(auth.get_user_by_email("badtype@example.com"))["project_slug"] is None
finally:
_cleanup_project("badtype@example.com")
def test_onboarding_rejects_removed_types(anon_client):
def test_onboarding_rejects_removed_types(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Les anciens types (quiz/embranchement/custom) ne sont plus des choix
valides — voir db/games/game_type_catalog.py, ONBOARDING_TYPES ne
contient plus que "rpg"."""
@@ -80,35 +85,37 @@ def test_onboarding_rejects_removed_types(anon_client):
for removed_type in ("quiz", "embranchement", "custom"):
resp = anon_client.post("/onboarding", data={"onboarding_type": removed_type, "name": "Test"})
assert resp.status_code == 200
assert auth.get_user_by_email("removedtype@example.com")["project_slug"] is None
assert not_none(auth.get_user_by_email("removedtype@example.com"))["project_slug"] is None
finally:
_cleanup_project("removedtype@example.com")
def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client):
def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "revisit@example.com")
_confirm_2fa(anon_client)
try:
first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg")
first_slug = auth.get_user_by_email("revisit@example.com")["project_slug"]
first_slug = not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"]
resp = anon_client.get("/onboarding", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"] == first.headers["Location"]
again = anon_client.post("/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False)
assert auth.get_user_by_email("revisit@example.com")["project_slug"] == first_slug
again = anon_client.post(
"/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False
)
assert not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] == first_slug
assert again.headers["Location"] == first.headers["Location"]
finally:
_cleanup_project("revisit@example.com")
def test_admin_is_never_redirected_to_onboarding(client, game):
def test_admin_is_never_redirected_to_onboarding(client: FlaskClient, game: str) -> None:
resp = client.get("/", follow_redirects=False)
assert resp.status_code == 200
def test_admin_can_create_several_games_via_onboarding(client):
def test_admin_can_create_several_games_via_onboarding(client: FlaskClient) -> None:
"""Comportement systématique à CHAQUE création de jeu, pas une
formalité réservée à la toute première inscription (voir le plan) —
un admin, illimité (pas de project_slug dédié), doit pouvoir repasser
@@ -133,6 +140,7 @@ def test_admin_can_create_several_games_via_onboarding(client):
assert slug2 != slug1
import tests.conftest as conftest_module
slugs = {g["slug"] for g in db.list_games(str(conftest_module._TEST_ADMIN_ID))}
assert slug1 in slugs
assert slug2 in slugs
+40 -17
View File
@@ -2,15 +2,20 @@
auth/send_email.py) — l'envoi SMTP réel est remplacé (monkeypatch) par une
capture de l'URL qui aurait été envoyée : ces tests ne doivent jamais
essayer de contacter un vrai serveur SMTP."""
from typing import Any
import pytest
from flask.testing import FlaskClient
import auth
from tests.test_auth import _register, _confirm_2fa, _cleanup_project, anon_client # noqa: F401
from tests.test_auth import _cleanup_project, _confirm_2fa, _register, anon_client # noqa: F401
def _capture_reset_url(monkeypatch):
def _capture_reset_url(monkeypatch: pytest.MonkeyPatch) -> Any:
sent = {}
def fake_send(to_email, reset_url):
def fake_send(to_email: str, reset_url: str) -> None:
sent["to_email"] = to_email
sent["reset_url"] = reset_url
@@ -18,14 +23,17 @@ def _capture_reset_url(monkeypatch):
return sent
def test_forgot_password_always_shows_the_same_generic_message(anon_client, monkeypatch):
def test_forgot_password_always_shows_the_same_generic_message(
anon_client: FlaskClient, # noqa: F811 - fixture pytest (voir tests.test_auth), pas une vraie redefinition
monkeypatch: pytest.MonkeyPatch,
) -> None:
_capture_reset_url(monkeypatch)
resp_known = anon_client.post("/forgot-password", data={"email": "unknown@example.com"})
resp_unknown_body = resp_known.get_data(as_text=True)
assert "un email vient de t'être envoyé" in resp_unknown_body
def test_full_password_reset_flow(anon_client, monkeypatch):
def test_full_password_reset_flow(anon_client: FlaskClient, monkeypatch: pytest.MonkeyPatch) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
sent = _capture_reset_url(monkeypatch)
_register(anon_client, "resetflow@example.com")
_confirm_2fa(anon_client)
@@ -43,28 +51,43 @@ def test_full_password_reset_flow(anon_client, monkeypatch):
assert "invalide" not in resp.get_data(as_text=True)
# Mots de passe qui ne correspondent pas -> refusé.
resp = anon_client.post(f"/reset-password/{token}", data={
"password": "Nouveau$ecret1", "password_confirm": "Autre$ecret2",
})
resp = anon_client.post(
f"/reset-password/{token}",
data={
"password": "Nouveau$ecret1",
"password_confirm": "Autre$ecret2",
},
)
assert "ne correspondent pas" in resp.get_data(as_text=True)
# Mot de passe trop faible -> refusé.
resp = anon_client.post(f"/reset-password/{token}", data={
"password": "faible", "password_confirm": "faible",
})
resp = anon_client.post(
f"/reset-password/{token}",
data={
"password": "faible",
"password_confirm": "faible",
},
)
assert "faible" in resp.get_data(as_text=True)
# Nouveau mot de passe valide -> accepté, redirige vers /login.
resp = anon_client.post(f"/reset-password/{token}", data={
"password": "Nouveau$ecret1", "password_confirm": "Nouveau$ecret1",
}, follow_redirects=False)
resp = anon_client.post(
f"/reset-password/{token}",
data={
"password": "Nouveau$ecret1",
"password_confirm": "Nouveau$ecret1",
},
follow_redirects=False,
)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login"
# L'ancien mot de passe ne fonctionne plus, le nouveau fonctionne.
resp = anon_client.post("/login", data={"email": "resetflow@example.com", "password": "Sup3r$ecret!"})
assert "incorrect" in resp.get_data(as_text=True)
resp = anon_client.post("/login", data={"email": "resetflow@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False)
resp = anon_client.post(
"/login", data={"email": "resetflow@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False
)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
@@ -75,6 +98,6 @@ def test_full_password_reset_flow(anon_client, monkeypatch):
_cleanup_project("resetflow@example.com")
def test_reset_password_with_invalid_or_unknown_token_shows_invalid(anon_client):
def test_reset_password_with_invalid_or_unknown_token_shows_invalid(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
resp = anon_client.get("/reset-password/not-a-real-token")
assert "invalide" in resp.get_data(as_text=True)
+55 -20
View File
@@ -6,56 +6,79 @@ axe. Ce fichier teste la couche serveur (défauts, route de sauvegarde,
exposition dans full_game_payload) ; static/js/play/__tests__/
personnage-controller.test.js teste le comportement client (tick,
bornage, blocage d'axe)."""
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_personnage_cmd"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_personnage_cmd"
) -> 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 _add_personnage(client, slug, screen_id, forge_character="zombie"):
def _add_personnage(client: FlaskClient, slug: str, screen_id: int, forge_character: str = "zombie") -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": forge_character},
follow_redirects=False,
)
assert resp.status_code == 302, resp.data
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
def test_freshly_placed_personnage_gets_default_zqsd_commands(client, tmp_game_slug_cleanup):
def test_freshly_placed_personnage_gets_default_zqsd_commands(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_commands(obj) == screens.DEFAULT_PERSONNAGE_COMMANDS
def test_commands_route_persists_remapped_keys_and_axis_lock(client, tmp_game_slug_cleanup):
def test_commands_route_persists_remapped_keys_and_axis_lock(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/commandes",
data='{"haut": "ArrowUp", "bas": "ArrowDown", "gauche": "ArrowLeft", "droite": "ArrowRight", "interagir": " ", "axe": "horizontal", "vitesse": 8}',
data=(
'{"haut": "ArrowUp", "bas": "ArrowDown", "gauche": "ArrowLeft", "droite": "ArrowRight", '
'"interagir": " ", "axe": "horizontal", "vitesse": 8}'
),
content_type="application/json",
)
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
commands = screens.resolve_personnage_commands(obj)
assert commands == {
"haut": "ArrowUp", "bas": "ArrowDown", "gauche": "ArrowLeft", "droite": "ArrowRight",
"interagir": " ", "axe": "horizontal", "vitesse": 8, "extra": [],
"haut": "ArrowUp",
"bas": "ArrowDown",
"gauche": "ArrowLeft",
"droite": "ArrowRight",
"interagir": " ",
"axe": "horizontal",
"vitesse": 8,
"extra": [],
}
def test_commands_route_persists_extra_animation_bound_keys(client, tmp_game_slug_cleanup):
def test_commands_route_persists_extra_animation_bound_keys(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Touches supplémentaires (sauter, attaquer...) — bug remonté par
l'utilisateur : au-delà des 4 touches de déplacement et de
"interagir", il manquait un moyen de lier une touche à N'IMPORTE
@@ -71,11 +94,13 @@ def test_commands_route_persists_extra_animation_bound_keys(client, tmp_game_slu
commands = resp.get_json()["commands"]
assert commands["extra"] == [{"touche": "j", "animation": "jump"}, {"touche": "a", "animation": "attack"}]
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_commands(obj)["extra"] == commands["extra"]
def test_commands_route_drops_malformed_extra_entries(client, tmp_game_slug_cleanup):
def test_commands_route_drops_malformed_extra_entries(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Une entrée sans touche/animation, un doublon de type, ou une liste
qui n'en est pas une : ignorée plutôt que de planter ou de polluer le
panneau avec une ligne inutilisable côté client."""
@@ -84,7 +109,10 @@ def test_commands_route_drops_malformed_extra_entries(client, tmp_game_slug_clea
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/commandes",
data='{"extra": [{"touche": "j", "animation": "jump"}, {"touche": ""}, {"animation": "attack"}, "pas un objet", 42]}',
data=(
'{"extra": [{"touche": "j", "animation": "jump"}, {"touche": ""}, '
'{"animation": "attack"}, "pas un objet", 42]}'
),
content_type="application/json",
)
assert resp.get_json()["commands"]["extra"] == [{"touche": "j", "animation": "jump"}]
@@ -97,7 +125,9 @@ def test_commands_route_drops_malformed_extra_entries(client, tmp_game_slug_clea
assert resp.get_json()["commands"]["extra"] == []
def test_commands_route_rejects_invalid_axis_and_clamps_speed(client, tmp_game_slug_cleanup):
def test_commands_route_rejects_invalid_axis_and_clamps_speed(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Un axe inconnu retombe sur "libre", une vitesse hors bornes est
ramenée dans [1, 20] — jamais une valeur qui planterait le calcul
d'un déplacement côté client (voir personnage-controller.js)."""
@@ -114,8 +144,10 @@ def test_commands_route_rejects_invalid_axis_and_clamps_speed(client, tmp_game_s
assert commands["vitesse"] == 20
def test_commands_route_falls_back_to_defaults_for_blank_or_missing_keys(client, tmp_game_slug_cleanup):
""""" (chaîne vide)/absent retombe sur le défaut — mais PAS " "
def test_commands_route_falls_back_to_defaults_for_blank_or_missing_keys(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
""" "" (chaîne vide)/absent retombe sur le défaut — mais PAS " "
(Espace), une valeur ev.key parfaitement valide côté client pour
"interagir" (voir la note dans routes/scenes/scene_object_commands.py)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
@@ -132,15 +164,18 @@ def test_commands_route_falls_back_to_defaults_for_blank_or_missing_keys(client,
assert commands["interagir"] == " "
def test_commands_route_404s_for_missing_object(client, game):
def test_commands_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/scene-objects/999999/commandes",
data="{}", content_type="application/json",
data="{}",
content_type="application/json",
)
assert resp.status_code == 404
def test_full_game_payload_exposes_resolved_commands_for_personnage_objects(client, tmp_game_slug_cleanup):
def test_full_game_payload_exposes_resolved_commands_for_personnage_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_personnage(client, slug, screen_id)
screens.add_scene_object(slug, screen_id, kind="decor") # jamais de commandes sur un décor
+41 -17
View File
@@ -3,25 +3,37 @@ screens/rendering/personnage_role.py) : seul un personnage "joueur" est
déplacé/animé automatiquement au clavier et suivi par la caméra (voir
static/js/play/personnage-controller.js, testé côté client dans
static/js/play/__tests__/personnage-controller.test.js)."""
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_role"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_role"
) -> 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 _add_personnage(client, slug, screen_id):
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
def _add_personnage(client: FlaskClient, slug: str, screen_id: int) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
)
assert resp.status_code == 302, resp.data
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
def test_freshly_placed_personnage_defaults_to_the_pnj_role(client, tmp_game_slug_cleanup):
def test_freshly_placed_personnage_defaults_to_the_pnj_role(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : un personnage fraîchement posé n'est JAMAIS le
joueur par défaut, même le tout premier de la scène — voir
screens/scenes/add_scene_object.py, qui enregistre "pnj" explicitement.
@@ -29,49 +41,61 @@ def test_freshly_placed_personnage_defaults_to_the_pnj_role(client, tmp_game_slu
explicite (menu "🏷️ Rôle" du panneau de propriétés)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_role(obj) == "pnj"
def test_second_personnage_also_defaults_to_the_pnj_role(client, tmp_game_slug_cleanup):
def test_second_personnage_also_defaults_to_the_pnj_role(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Non-régression : un deuxième personnage ne doit pas non plus
retomber sur "joueur" — sinon il se faisait exclure à tort de "🧩
Collision" (voir scene_edit_view.py, qui exclut TOUJOURS le joueur)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_personnage(client, slug, screen_id)
second_id = _add_personnage(client, slug, screen_id)
obj = screens.get_scene_object(slug, second_id)
obj = not_none(screens.get_scene_object(slug, second_id))
assert screens.resolve_personnage_role(obj) == "pnj"
def test_role_route_persists_a_valid_role(client, tmp_game_slug_cleanup):
def test_role_route_persists_a_valid_role(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "pnj"}', content_type="application/json")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "pnj"}', content_type="application/json"
)
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "role": "pnj"}
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_role(obj) == "pnj"
def test_role_route_rejects_an_unknown_role(client, tmp_game_slug_cleanup):
def test_role_route_rejects_an_unknown_role(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "boss-final"}', content_type="application/json")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "boss-final"}', content_type="application/json"
)
assert resp.get_json()["role"] == "joueur" # retombe sur le défaut plutôt que d'enregistrer n'importe quoi
def test_role_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/role", data='{"role": "ennemie"}', content_type="application/json")
def test_role_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/scene-objects/999999/role", data='{"role": "ennemie"}', content_type="application/json"
)
assert resp.status_code == 404
def test_full_game_payload_exposes_the_resolved_role_for_personnage_objects(client, tmp_game_slug_cleanup):
def test_full_game_payload_exposes_the_resolved_role_for_personnage_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id)
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json")
client.post(
f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json"
)
payload = screens.full_game_payload(slug)
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
+44 -29
View File
@@ -6,37 +6,41 @@ joueurs. L'aperçu créateur (PLAYER_SHARED, le défaut de tous les
paramètres player_id) doit continuer de fonctionner exactement comme
avant ces changements — voir tests/test_confort.py, test_flow.py etc., qui
n'ont pas eu besoin d'être modifiés."""
from flask.testing import FlaskClient
import db
from tests.conftest import not_none
def test_global_variable_per_player_gives_independent_values(game):
def test_global_variable_per_player_gives_independent_values(game: str) -> None:
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
db.update_global_variable_value(game, "score", "99", player_id="bob")
assert db.get_global_variable(game, "score", player_id="alice")["value"] == "10"
assert db.get_global_variable(game, "score", player_id="bob")["value"] == "99"
assert not_none(db.get_global_variable(game, "score", player_id="alice"))["value"] == "10"
assert not_none(db.get_global_variable(game, "score", player_id="bob"))["value"] == "99"
# Le créateur (PLAYER_SHARED, la ligne "modèle") garde la valeur par
# défaut — jamais écrasée par les joueurs.
assert db.get_global_variable(game, "score")["value"] == "0"
assert not_none(db.get_global_variable(game, "score"))["value"] == "0"
def test_global_variable_new_player_falls_back_to_default_value(game):
def test_global_variable_new_player_falls_back_to_default_value(game: str) -> None:
db.create_global_variable(game, "vies", "nombre_entier", "3", per_player=True)
assert db.get_global_variable(game, "vies", player_id="charlie")["value"] == "3"
assert not_none(db.get_global_variable(game, "vies", player_id="charlie"))["value"] == "3"
def test_global_variable_shared_ignores_player_id(game):
def test_global_variable_shared_ignores_player_id(game: str) -> None:
db.create_global_variable(game, "compteur_visites", "nombre_entier", "0", per_player=False)
db.update_global_variable_value(game, "compteur_visites", "1", player_id="alice")
db.update_global_variable_value(game, "compteur_visites", "2", player_id="bob")
# Même valeur pour tout le monde, y compris un 3e joueur qui n'a jamais écrit.
assert db.get_global_variable(game, "compteur_visites", player_id="alice")["value"] == "2"
assert db.get_global_variable(game, "compteur_visites", player_id="bob")["value"] == "2"
assert db.get_global_variable(game, "compteur_visites", player_id="charlie")["value"] == "2"
assert not_none(db.get_global_variable(game, "compteur_visites", player_id="alice"))["value"] == "2"
assert not_none(db.get_global_variable(game, "compteur_visites", player_id="bob"))["value"] == "2"
assert not_none(db.get_global_variable(game, "compteur_visites", player_id="charlie"))["value"] == "2"
def test_global_variable_list_only_shows_template_row(game):
def test_global_variable_list_only_shows_template_row(game: str) -> None:
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
db.update_global_variable_value(game, "score", "10", player_id="alice")
variables = db.list_global_variables(game)
@@ -44,19 +48,19 @@ def test_global_variable_list_only_shows_template_row(game):
assert variables[0]["value"] == "0"
def test_global_variable_delete_by_id_cascades_all_players(game):
var_id = db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
def test_global_variable_delete_by_id_cascades_all_players(game: str) -> None:
var_id = not_none(db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True))
db.update_global_variable_value(game, "score", "10", player_id="alice")
db.delete_global_variable_by_id(game, var_id)
assert db.get_global_variable(game, "score", player_id="alice") is None
assert db.get_global_variable(game, "score") is None
def test_object_per_player_gives_independent_rows(game):
def test_object_per_player_gives_independent_rows(game: str) -> None:
definition_id = db.create_definition(
game, "Inventaire", [{"name": "objet", "type": "texte", "required": False}], per_player=True
)
definition = db.get_definition(game, definition_id)
definition = not_none(db.get_definition(game, definition_id))
db.insert_row(game, definition, {"objet": "épée"}, player_id="alice")
db.insert_row(game, definition, {"objet": "bouclier"}, player_id="bob")
@@ -69,11 +73,11 @@ def test_object_per_player_gives_independent_rows(game):
assert db.list_rows(game, definition, player_id="charlie") == []
def test_object_shared_gives_same_rows_to_every_player(game):
def test_object_shared_gives_same_rows_to_every_player(game: str) -> None:
definition_id = db.create_definition(
game, "Catalogue", [{"name": "nom", "type": "texte", "required": False}], per_player=False
)
definition = db.get_definition(game, definition_id)
definition = not_none(db.get_definition(game, definition_id))
db.insert_row(game, definition, {"nom": "Potion"})
alice_rows = db.list_rows(game, definition, player_id="alice")
bob_rows = db.list_rows(game, definition, player_id="bob")
@@ -81,41 +85,52 @@ def test_object_shared_gives_same_rows_to_every_player(game):
assert [r["nom"] for r in bob_rows] == ["Potion"]
def test_get_row_does_not_leak_across_players(game):
def test_get_row_does_not_leak_across_players(game: str) -> None:
definition_id = db.create_definition(
game, "Inventaire", [{"name": "objet", "type": "texte", "required": False}], per_player=True
)
definition = db.get_definition(game, definition_id)
definition = not_none(db.get_definition(game, definition_id))
row_id = db.insert_row(game, definition, {"objet": "épée"}, player_id="alice")
# Bob ne doit jamais pouvoir lire/modifier/supprimer la ligne d'Alice,
# même en devinant son row_id (garde-fou de sécurité pour la route
# publique /jouer/<slug>).
assert db.get_row(game, definition, row_id, player_id="bob") is None
db.update_row_field(game, definition, row_id, definition["fields"][0], "hache", player_id="bob")
assert db.get_row(game, definition, row_id, player_id="alice")["objet"] == "épée"
assert not_none(db.get_row(game, definition, row_id, player_id="alice"))["objet"] == "épée"
db.delete_row(game, definition, row_id, player_id="bob")
assert db.get_row(game, definition, row_id, player_id="alice") is not None
def test_create_global_var_route_respects_per_player_checkbox(client, game):
client.post(f"/game/{game}/variables/new", data={
"name": "score", "type": "nombre_entier", "default_value": "0",
})
def test_create_global_var_route_respects_per_player_checkbox(client: FlaskClient, game: str) -> None:
client.post(
f"/game/{game}/variables/new",
data={
"name": "score",
"type": "nombre_entier",
"default_value": "0",
},
)
assert db.list_global_variables(game)[0]["per_player"] == 1
client.post(f"/game/{game}/variables/new", data={
"name": "compteur_visites", "type": "nombre_entier", "default_value": "0", "per_player": "0",
})
client.post(
f"/game/{game}/variables/new",
data={
"name": "compteur_visites",
"type": "nombre_entier",
"default_value": "0",
"per_player": "0",
},
)
variables = {v["name"]: v for v in db.list_global_variables(game)}
assert variables["compteur_visites"]["per_player"] == 0
def test_creator_preview_default_matches_shared_definition_behavior(game):
def test_creator_preview_default_matches_shared_definition_behavior(game: str) -> None:
"""L'aperçu créateur (aucun player_id passé nulle part, donc
PLAYER_SHARED partout) doit continuer de fonctionner exactement comme
avant l'état par joueur — un seul appelant, un seul jeu de lignes."""
definition_id = db.create_definition(game, "Notes", [{"name": "texte", "type": "texte", "required": False}])
definition = db.get_definition(game, definition_id)
definition = not_none(db.get_definition(game, definition_id))
db.insert_row(game, definition, {"texte": "bonjour"})
rows = db.list_rows(game, definition)
assert [r["texte"] for r in rows] == ["bonjour"]
+26 -11
View File
@@ -8,41 +8,56 @@ logique existe dans le JS livré et que les nœuds persistent leurs champs,
pas qu'ils s'exécutent). Le nœud de condition de collision entre deux
objets de scène (cond_element_a/cond_element_b) est déjà couvert par
test_scene_edit_view.py::test_flow_node_collision_condition_targets_scene_objects."""
from conftest import play_js_bundle
from typing import Any
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_screen_and_object(slug):
def _create_screen_and_object(slug: str) -> Any:
screen_id = screens.create_screen(slug, "Accueil", kind="jeu_2d")
obj_id = screens.add_scene_object(slug, screen_id)
return screen_id, obj_id
def test_position_action_node_persists_absolute_property(game):
def test_position_action_node_persists_absolute_property(game: str) -> None:
screen_id, obj_id = _create_screen_and_object(game)
node_id = screens.add_flow_node(
game, screen_id, node_type="action", action_type="modifier_objet_scene",
target_object_id=obj_id, element_property="pos_x", element_value="42",
game,
screen_id,
node_type="action",
action_type="modifier_objet_scene",
target_object_id=obj_id,
element_property="pos_x",
element_value="42",
)
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["element_property"] == "pos_x"
assert node["element_value"] == "42"
assert node["target_object_id"] == obj_id
def test_position_action_node_persists_relative_property(game):
def test_position_action_node_persists_relative_property(game: str) -> None:
screen_id, obj_id = _create_screen_and_object(game)
node_id = screens.add_flow_node(
game, screen_id, node_type="action", action_type="modifier_objet_scene",
target_object_id=obj_id, element_property="pos_y_relatif", element_value="-5",
game,
screen_id,
node_type="action",
action_type="modifier_objet_scene",
target_object_id=obj_id,
element_property="pos_y_relatif",
element_value="-5",
)
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["element_property"] == "pos_y_relatif"
assert node["element_value"] == "-5"
def test_play_page_exposes_position_and_collision_runtime(client, game):
def test_play_page_exposes_position_and_collision_runtime(client: FlaskClient, game: str) -> None:
js = play_js_bundle()
assert "pos_x" in js
assert "pos_y" in js
+84 -45
View File
@@ -1,15 +1,16 @@
"""Page de profil (routes/auth/profile.py) : modifier nom/prénom, changer
de mot de passe, demander la suppression de son compte (avec mot de passe
+ confirmation textuelle, voir templates/auth/profile.html)."""
import pyotp
from flask.testing import FlaskClient
import auth
import db
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project, anon_client # noqa: F401
from tests.conftest import not_none
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register, anon_client # noqa: F401
def test_profile_page_shows_current_user_info(anon_client):
def test_profile_page_shows_current_user_info(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "profileinfo@example.com", prenom="Ada", nom="Lovelace")
_confirm_2fa(anon_client)
try:
@@ -21,20 +22,20 @@ def test_profile_page_shows_current_user_info(anon_client):
_cleanup_project("profileinfo@example.com")
def test_update_name(anon_client):
def test_update_name(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "updatename@example.com", prenom="Old", nom="Name")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-name", data={"prenom": "New", "nom": "Name2"})
assert "mises à jour" in resp.get_data(as_text=True)
user = auth.get_user_by_email("updatename@example.com")
user = not_none(auth.get_user_by_email("updatename@example.com"))
assert user["prenom"] == "New"
assert user["nom"] == "Name2"
finally:
_cleanup_project("updatename@example.com")
def test_update_name_requires_both_fields(anon_client):
def test_update_name_requires_both_fields(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "updatenameblank@example.com")
_confirm_2fa(anon_client)
try:
@@ -44,60 +45,82 @@ def test_update_name_requires_both_fields(anon_client):
_cleanup_project("updatenameblank@example.com")
def test_change_password_requires_correct_current_password(anon_client):
def test_change_password_requires_correct_current_password(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "changepwwrong@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "wrong", "new_password": "Nouveau$ecret1", "new_password_confirm": "Nouveau$ecret1",
})
resp = anon_client.post(
"/profile/change-password",
data={
"current_password": "wrong",
"new_password": "Nouveau$ecret1",
"new_password_confirm": "Nouveau$ecret1",
},
)
assert "incorrect" in resp.get_data(as_text=True)
finally:
_cleanup_project("changepwwrong@example.com")
def test_change_password_rejects_mismatched_confirmation(anon_client):
def test_change_password_rejects_mismatched_confirmation(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "changepwmismatch@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "Sup3r$ecret!", "new_password": "Nouveau$ecret1", "new_password_confirm": "Autre$ecret2",
})
resp = anon_client.post(
"/profile/change-password",
data={
"current_password": "Sup3r$ecret!",
"new_password": "Nouveau$ecret1",
"new_password_confirm": "Autre$ecret2",
},
)
assert "ne correspondent pas" in resp.get_data(as_text=True)
finally:
_cleanup_project("changepwmismatch@example.com")
def test_change_password_rejects_a_weak_new_password(anon_client):
def test_change_password_rejects_a_weak_new_password(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "changepwweak@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "Sup3r$ecret!", "new_password": "faible", "new_password_confirm": "faible",
})
resp = anon_client.post(
"/profile/change-password",
data={
"current_password": "Sup3r$ecret!",
"new_password": "faible",
"new_password_confirm": "faible",
},
)
assert "faible" in resp.get_data(as_text=True)
finally:
_cleanup_project("changepwweak@example.com")
def test_change_password_success_lets_login_with_new_password(anon_client):
def test_change_password_success_lets_login_with_new_password(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "changepwok@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/change-password", data={
"current_password": "Sup3r$ecret!", "new_password": "Nouveau$ecret1", "new_password_confirm": "Nouveau$ecret1",
})
resp = anon_client.post(
"/profile/change-password",
data={
"current_password": "Sup3r$ecret!",
"new_password": "Nouveau$ecret1",
"new_password_confirm": "Nouveau$ecret1",
},
)
assert "modifié" in resp.get_data(as_text=True)
anon_client.post("/logout")
resp = anon_client.post("/login", data={"email": "changepwok@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False)
resp = anon_client.post(
"/login", data={"email": "changepwok@example.com", "password": "Nouveau$ecret1"}, follow_redirects=False
)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
finally:
_cleanup_project("changepwok@example.com")
def test_delete_account_requires_correct_password(anon_client):
def test_delete_account_requires_correct_password(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "deletewrongpw@example.com")
_confirm_2fa(anon_client)
try:
@@ -108,7 +131,7 @@ def test_delete_account_requires_correct_password(anon_client):
_cleanup_project("deletewrongpw@example.com")
def test_delete_account_requires_the_confirmation_phrase(anon_client):
def test_delete_account_requires_the_confirmation_phrase(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "deletenoconfirm@example.com")
_confirm_2fa(anon_client)
try:
@@ -119,32 +142,38 @@ def test_delete_account_requires_the_confirmation_phrase(anon_client):
_cleanup_project("deletenoconfirm@example.com")
def test_delete_account_removes_user_and_their_project(anon_client):
def test_delete_account_removes_user_and_their_project(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "deleteforreal@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
user = auth.get_user_by_email("deleteforreal@example.com")
user = not_none(auth.get_user_by_email("deleteforreal@example.com"))
slug = user["project_slug"]
import os
assert os.path.isdir(db.game_dir(slug))
resp = anon_client.post("/profile/delete", data={"current_password": "Sup3r$ecret!", "confirm_text": "SUPPRIMER"}, follow_redirects=False)
resp = anon_client.post(
"/profile/delete",
data={"current_password": "Sup3r$ecret!", "confirm_text": "SUPPRIMER"},
follow_redirects=False,
)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login"
assert auth.get_user_by_email("deleteforreal@example.com") is None
assert not os.path.isdir(db.game_dir(slug))
def test_the_only_admin_account_cannot_be_deleted(client):
def test_the_only_admin_account_cannot_be_deleted(client: FlaskClient) -> None:
"""`client` (conftest.py) est déjà connecté en tant qu'admin unique de
la session de tests."""
resp = client.post("/profile/delete", data={"current_password": "Test1234!", "confirm_text": "SUPPRIMER"})
assert "administrateur" in resp.get_data(as_text=True)
import tests.conftest as conftest_module
assert auth.get_user_by_id(conftest_module._TEST_ADMIN_ID) is not None
def test_regenerate_recovery_codes_requires_correct_password(anon_client):
def test_regenerate_recovery_codes_requires_correct_password(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "regencodeswrong@example.com")
_confirm_2fa(anon_client)
try:
@@ -154,10 +183,10 @@ def test_regenerate_recovery_codes_requires_correct_password(anon_client):
_cleanup_project("regencodeswrong@example.com")
def test_regenerate_recovery_codes_replaces_the_old_ones_and_shows_them_once(anon_client):
def test_regenerate_recovery_codes_replaces_the_old_ones_and_shows_them_once(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "regencodesok@example.com")
_confirm_2fa(anon_client)
user = auth.get_user_by_email("regencodesok@example.com")
user = not_none(auth.get_user_by_email("regencodesok@example.com"))
try:
old_codes = auth.generate_recovery_codes(user["id"])
@@ -179,7 +208,7 @@ def test_regenerate_recovery_codes_replaces_the_old_ones_and_shows_them_once(ano
_cleanup_project("regencodesok@example.com")
def test_update_email_requires_correct_password(anon_client):
def test_update_email_requires_correct_password(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "emailwrongpw@example.com")
_confirm_2fa(anon_client)
try:
@@ -190,31 +219,35 @@ def test_update_email_requires_correct_password(anon_client):
_cleanup_project("emailwrongpw@example.com")
def test_update_email_rejects_invalid_format(anon_client):
def test_update_email_rejects_invalid_format(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "emailinvalid@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "not-an-email", "current_password": "Sup3r$ecret!"})
resp = anon_client.post(
"/profile/update-email", data={"email": "not-an-email", "current_password": "Sup3r$ecret!"}
)
assert "invalide" in resp.get_data(as_text=True)
finally:
_cleanup_project("emailinvalid@example.com")
def test_update_email_rejects_an_email_already_used(anon_client):
def test_update_email_rejects_an_email_already_used(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
_register(anon_client, "emailtaken1@example.com")
_confirm_2fa(anon_client)
anon_client.post("/logout")
_register(anon_client, "emailtaken2@example.com")
_confirm_2fa(anon_client)
try:
resp = anon_client.post("/profile/update-email", data={"email": "emailtaken1@example.com", "current_password": "Sup3r$ecret!"})
resp = anon_client.post(
"/profile/update-email", data={"email": "emailtaken1@example.com", "current_password": "Sup3r$ecret!"}
)
assert "existe déjà" in resp.get_data(as_text=True)
finally:
_cleanup_project("emailtaken1@example.com")
_cleanup_project("emailtaken2@example.com")
def test_update_email_never_touches_the_project_folder(anon_client):
def test_update_email_never_touches_the_project_folder(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Le dossier propriétaire d'un projet est nommé d'après l'ID du
compte (voir routes/games/games_new.py, opaque et stable — jamais
l'email, voir scripts/migrate_owner_folders_to_user_id.py pour la
@@ -226,11 +259,13 @@ def test_update_email_never_touches_the_project_folder(anon_client):
_register(anon_client, "oldmail@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
old_user = auth.get_user_by_email("oldmail@example.com")
old_user = not_none(auth.get_user_by_email("oldmail@example.com"))
old_slug = old_user["project_slug"]
assert os.path.isdir(db.game_dir(old_slug))
try:
resp = anon_client.post("/profile/update-email", data={"email": "newmail@example.com", "current_password": "Sup3r$ecret!"})
resp = anon_client.post(
"/profile/update-email", data={"email": "newmail@example.com", "current_password": "Sup3r$ecret!"}
)
assert "mise à jour" in resp.get_data(as_text=True)
assert auth.get_user_by_email("oldmail@example.com") is None
@@ -242,7 +277,9 @@ def test_update_email_never_touches_the_project_folder(anon_client):
# La connexion se fait désormais avec la nouvelle adresse.
anon_client.post("/logout")
resp = anon_client.post("/login", data={"email": "newmail@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False)
resp = anon_client.post(
"/login", data={"email": "newmail@example.com", "password": "Sup3r$ecret!"}, follow_redirects=False
)
assert resp.status_code == 302
assert resp.headers["Location"] == "/login/2fa"
finally:
@@ -250,7 +287,7 @@ def test_update_email_never_touches_the_project_folder(anon_client):
_cleanup_project("newmail@example.com")
def test_update_email_for_an_admin_does_not_touch_any_project_folder(client, game):
def test_update_email_for_an_admin_does_not_touch_any_project_folder(client: FlaskClient, game: str) -> None:
"""`client`/`game` (conftest.py) : compte admin, pas de project_slug
dédié — changer son adresse ne doit renommer aucun dossier. L'admin de
test est PARTAGÉ par toute la session de tests (voir conftest.py) :
@@ -259,10 +296,12 @@ def test_update_email_for_an_admin_does_not_touch_any_project_folder(client, gam
import tests.conftest as conftest_module
try:
resp = client.post("/profile/update-email", data={"email": "newadminmail@example.com", "current_password": "Test1234!"})
resp = client.post(
"/profile/update-email", data={"email": "newadminmail@example.com", "current_password": "Test1234!"}
)
assert "mise à jour" in resp.get_data(as_text=True)
user = auth.get_user_by_id(conftest_module._TEST_ADMIN_ID)
user = not_none(auth.get_user_by_id(conftest_module._TEST_ADMIN_ID))
assert user["email"] == "newadminmail@example.com"
assert user["project_slug"] is None
assert db.game_meta(game) is not None
+223 -97
View File
@@ -5,11 +5,18 @@ quiz_box_config.py, screens/rendering/quiz_box_templates.py. DISTINCT de
"🎨 Style" (dialogue_box_style.py, partagé avec dialogue_box/score_widget,
voir test_dialogue_box.py) : ce fichier ne teste que plein écran/
minuteur/modèle."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_quiz_config"):
def _create_jeu2d_game(tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_quiz_config") -> Any:
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
@@ -17,12 +24,15 @@ def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_quiz_config"):
# ---------- Sanitizer/résolution ----------
def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_template(tmp_game_slug_cleanup):
""""Aucun" minuteur/plein écran désactivé par défaut — demande
def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_template(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
""" "Aucun" minuteur/plein écran désactivé par défaut — demande
explicite : jamais imposé, seulement au choix du créateur."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
config = screens.resolve_quiz_box_config(obj)
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
assert config["fullscreen"] is False
@@ -31,27 +41,39 @@ def test_default_quiz_box_config_has_no_fullscreen_no_timer_and_the_defaut_templ
assert config["page_template"] == "classique"
def test_sanitize_quiz_box_config_clamps_timer_seconds(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_clamps_timer_seconds(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 999999})
config = not_none(screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 999999}))
assert config["timer_seconds"] == 600
config = screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 0})
config = not_none(screens.set_scene_object_quiz_config(slug, obj_id, {"timer_seconds": 0}))
assert config["timer_seconds"] == 3
def test_sanitize_quiz_box_config_rejects_unknown_timer_mode_and_templates(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_rejects_unknown_timer_mode_and_templates(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {
"timer_mode": "toutes-les-secondes", "dialog_template": "arc-en-ciel", "page_template": "aquarelle",
})
config = not_none(
screens.set_scene_object_quiz_config(
slug,
obj_id,
{
"timer_mode": "toutes-les-secondes",
"dialog_template": "arc-en-ciel",
"page_template": "aquarelle",
},
)
)
assert config["timer_mode"] == "aucun"
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "classique"
def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : les deux réglages sont conservés
INDÉPENDAMMENT — un modèle "boîte" invalide ne doit jamais affecter le
modèle "page" valide, et réciproquement ("verre" n'existe plus depuis
@@ -60,46 +82,65 @@ def test_sanitize_quiz_box_config_keeps_dialog_and_page_template_independent(tmp
page_template valide à côté)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "verre", "page_template": "manga"})
config = not_none(
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "verre", "page_template": "manga"})
)
assert config["dialog_template"] == "defaut"
assert config["page_template"] == "manga"
def test_sanitize_quiz_box_config_ignores_a_non_dict_input(tmp_game_slug_cleanup):
def test_sanitize_quiz_box_config_ignores_a_non_dict_input(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
config = screens.set_scene_object_quiz_config(slug, obj_id, "pas-un-dict")
assert config == screens.DEFAULT_QUIZ_BOX_CONFIG
def test_set_scene_object_quiz_config_persists_and_sanitizes(tmp_game_slug_cleanup):
def test_set_scene_object_quiz_config_persists_and_sanitizes(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug,
obj_id,
{
"fullscreen": True,
"timer_mode": "question",
"timer_seconds": 15,
"dialog_template": "defaut",
"page_template": "manga",
},
)
obj = not_none(screens.get_scene_object(slug, obj_id))
config = screens.resolve_quiz_box_config(obj)
assert config == {
"fullscreen": True, "timer_mode": "question", "timer_seconds": 15,
"dialog_template": "defaut", "page_template": "manga",
"fullscreen": True,
"timer_mode": "question",
"timer_seconds": 15,
"dialog_template": "defaut",
"page_template": "manga",
}
def test_set_scene_object_quiz_config_returns_none_for_unknown_object(tmp_game_slug_cleanup):
def test_set_scene_object_quiz_config_returns_none_for_unknown_object(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, _ = _create_jeu2d_game(tmp_game_slug_cleanup)
assert screens.set_scene_object_quiz_config(slug, 999999, {}) is None
# ---------- Route ----------
def test_quiz_config_route_persists_config_and_returns_rendered_html(client, tmp_game_slug_cleanup):
def test_quiz_config_route_persists_config_and_returns_rendered_html(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/quiz-config",
data='{"config": {"fullscreen": false, "timer_mode": "quiz", "timer_seconds": 30, "dialog_template": "defaut"}}',
data=(
'{"config": {"fullscreen": false, "timer_mode": "quiz", "timer_seconds": 30, "dialog_template": "defaut"}}'
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -107,11 +148,13 @@ def test_quiz_config_route_persists_config_and_returns_rendered_html(client, tmp
assert body["config"]["fullscreen"] is False
assert body["config"]["dialog_template"] == "defaut"
assert 'class="quizBoxWidget"' in body["rendered_html"]
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_quiz_box_config(obj)["dialog_template"] == "defaut"
def test_quiz_config_route_page_template_only_applies_when_fullscreen(client, tmp_game_slug_cleanup):
def test_quiz_config_route_page_template_only_applies_when_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
resp = client.post(
@@ -125,8 +168,10 @@ def test_quiz_config_route_page_template_only_applies_when_fullscreen(client, tm
assert "quizBoxWidget--manga" in body["rendered_html"]
def test_quiz_config_route_404s_for_missing_object(client, game):
resp = client.post(f"/game/{game}/scene-objects/999999/quiz-config", data='{"config": {}}', content_type="application/json")
def test_quiz_config_route_404s_for_missing_object(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/scene-objects/999999/quiz-config", data='{"config": {}}', content_type="application/json"
)
assert resp.status_code == 404
@@ -135,12 +180,15 @@ def test_quiz_config_route_404s_for_missing_object(client, game):
# présentation, pour que le moteur JS partagé fonctionne quel que soit le
# modèle choisi ----------
def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(tmp_game_slug_cleanup):
def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Non-régression explicite : un quiz existant (jamais reconfiguré)
doit rester visuellement identique à avant cette extension."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "quizBoxWidget--" not in html
assert 'data-quiz-role="header"' in html
@@ -151,7 +199,9 @@ def test_render_quiz_box_defaut_is_unchanged_for_a_non_reconfigured_object(tmp_g
assert "quizBoxFooter" not in html
def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_slug_cleanup):
def test_render_quiz_box_header_and_topbar_share_the_same_header_row(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "le score doit être à l'intérieur de la boîte
à quiz, pas à l'extérieur" — le score (topbar) et le titre (header)
doivent être frères DANS le même bandeau ("quizBoxHeaderRow"), jamais
@@ -160,7 +210,7 @@ def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_sl
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
row_start = html.index("quizBoxHeaderRow")
row_end = html.index("quizBoxBody") # le bandeau se termine avant le corps
@@ -168,7 +218,9 @@ def test_render_quiz_box_header_and_topbar_share_the_same_header_row(tmp_game_sl
assert row_start < html.index('data-quiz-role="topbar"', row_start) < row_end, template
def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors(tmp_game_slug_cleanup):
def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_role_anchors(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Contrat commun (voir §3 du plan) — vérifié ici pour les modèles
"boîte de dialogue" (fullscreen=False, voir le test jumeau pour les
modèles "page de quiz")."""
@@ -176,7 +228,7 @@ def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_ro
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
for template in screens.QUIZ_BOX_DIALOG_TEMPLATES:
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": template})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
for role in ("header", "question", "choices", "topbar", "score", "timer"):
assert f'data-quiz-role="{role}"' in html, f"modèle {template!r} : ancrage {role!r} manquant"
@@ -184,7 +236,9 @@ def test_render_quiz_box_every_dialog_template_exposes_the_required_data_quiz_ro
assert 'class="quizBoxWidget' in html
def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role_anchors(tmp_game_slug_cleanup):
def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role_anchors(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Même contrat, pour les modèles "page de quiz" (fullscreen=True) —
"topbar" n'est PAS exigé ici (voir le contrat assoupli dans
screens/rendering/quiz_box_templates.py : un modèle "page" n'a pas
@@ -195,7 +249,7 @@ def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
for template in screens.QUIZ_BOX_PAGE_TEMPLATES:
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": template})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
for role in ("header", "question", "choices", "score", "timer"):
assert f'data-quiz-role="{role}"' in html, f"modèle {template!r} : ancrage {role!r} manquant"
@@ -203,42 +257,54 @@ def test_render_quiz_box_every_page_template_exposes_the_required_data_quiz_role
assert 'class="quizBoxWidget' in html
def test_render_quiz_box_reflects_fullscreen_and_timer_data_attributes(tmp_game_slug_cleanup):
def test_render_quiz_box_reflects_fullscreen_and_timer_data_attributes(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 42})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 42}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'data-fullscreen="1"' in html
assert 'data-timer-mode="question"' in html
assert 'data-timer-seconds="42"' in html
def test_render_quiz_box_manga_shows_aucun_under_temps_when_no_timer_is_configured(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_shows_aucun_under_temps_when_no_timer_is_configured(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "garde le temps mais si il n'y a pas de chrono
marque en dessous de temps aucun" — la plaque "Temps" reste TOUJOURS
visible (jamais masquée), avec "Aucun" en son absence."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "aucun", "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "aucun", "page_template": "manga"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer">Aucun</span>' in html
def test_render_quiz_box_manga_leaves_the_timer_value_empty_when_a_timer_is_configured(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_leaves_the_timer_value_empty_when_a_timer_is_configured(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Un vrai minuteur est rempli par le moteur JS (compte à rebours), pas
par le rendu serveur — jamais "Aucun" affiché en même temps qu'un
minuteur réellement configuré."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 20, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "timer_mode": "question", "timer_seconds": 20, "page_template": "manga"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="mangaPlateValue quizBoxTimerDisplay" data-quiz-role="timer"></span>' in html
def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""Demande explicite : "il n'y a pas le steper" — le HTML doit exposer
un conteneur `data-manga-role="progress"` que le JS (forgeQuizMangaOnShow,
static/js/play/quiz-box-templates.js) remplit au jeu (nombre de questions
@@ -246,12 +312,14 @@ def test_render_quiz_box_manga_has_a_progress_stepper_placeholder(tmp_game_slug_
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="mangaProgress" data-manga-role="progress"></div>' in html
def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "il faut aussi adapter le modele manga pour
avoir sa page de résultat" — voir forgeQuizMangaOnQuizComplete,
static/js/play/quiz-box-templates.js, qui remplit/affiche ce
@@ -259,13 +327,15 @@ def test_render_quiz_box_manga_has_an_always_empty_result_zone_placeholder(tmp_g
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="mangaResultZone" data-manga-role="result"></div>' in html
def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(tmp_game_slug_cleanup):
""""Manga" (boîte de dialogue) — demande explicite : "il faut créer un
def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
""" "Manga" (boîte de dialogue) — demande explicite : "il faut créer un
modele de boite themé mangas qui va avec le mode de page mangas",
validé via une maquette interactive. Reste une petite carte flottante
(fullscreen=False), structure PARTAGÉE avec les autres modèles
@@ -274,14 +344,16 @@ def test_render_quiz_box_manga_dialogue_applies_its_modifier_class_and_frame(tmp
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'class="quizBoxWidget quizBoxWidget--mangaDialogue"' in html
assert '<div class="quizMangaDialogueFrame">' in html
assert 'data-quiz-template="manga_dialogue"' in html
def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "le modele dois etre une copie conforme a la
maquete au pixel pret" — un bandeau de pied de page sombre reste
TOUJOURS visible (même principe que le modèle "page" "Classique"),
@@ -290,17 +362,19 @@ def test_render_quiz_box_manga_dialogue_has_an_always_visible_footer_band(tmp_ga
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="quizMangaDialogueFooterBand" aria-hidden="true"></div>' in html
# "Titre de la quête" seul (jamais "Quête : Titre de la quête") — le
# préfixe "Quête : " est posé en CSS (::before), pas dans le HTML,
# sinon il apparaîtrait EN DOUBLE dans l'aperçu éditeur.
assert '>Titre de la quête<' in html
assert '>Quête : Titre de la quête<' not in html
assert ">Titre de la quête<" in html
assert ">Quête : Titre de la quête<" not in html
def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite (répétée : "il est ou le bouton continuer !!!!!",
puis "le bouton continuer est toujours visible mais activer seulement
quand un choix est fait") — le vrai bouton, comme ce repère d'aperçu
@@ -310,12 +384,14 @@ def test_render_quiz_box_manga_dialogue_shows_a_disabled_continue_button_placeho
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>' in html
def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_continue_button(tmp_game_slug_cleanup):
def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_continue_button(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "le contener qui contien les choix a les bord
du bas qui ne sont pas pointue" — le coin dentelé du bas est un
simple `background` (dégradé) sur .quizBoxBody, PAS un clip-path ni
@@ -327,7 +403,7 @@ def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_con
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "manga_dialogue"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "quizMangaDialogueBodyBg" not in html
assert (
@@ -335,26 +411,28 @@ def test_render_quiz_box_manga_dialogue_body_has_no_extra_wrapper_around_the_con
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix A</button>'
'<button type="button" class="quizBoxChoiceBtn" disabled>Choix B</button>'
'<button type="button" class="quizBoxContinueBtn" disabled>Continuer →</button>'
'</div>'
"</div>"
) in html
def test_render_quiz_box_classique_is_the_default_page_template(tmp_game_slug_cleanup):
""""Classique" — modèle de PAGE DE QUIZ DE BASE, devient le réglage
def test_render_quiz_box_classique_is_the_default_page_template(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
""" "Classique" — modèle de PAGE DE QUIZ DE BASE, devient le réglage
par défaut (au même titre que "defaut"/Classique côté "boîte de
dialogue") : un créateur qui active juste "Plein écran" sans jamais
toucher aux modèles obtient ce modèle sobre, jamais "manga"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
config = screens.resolve_quiz_box_config(obj)
assert config["page_template"] == "classique"
html = screens.render_scene_object(obj)
assert 'class="quizBoxWidget quizBoxWidget--classique"' in html
def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "aujourd'hui un quiz n'a pas de titre mais il y
a un champs nom qui peut servir de titre pour le modele" — pas de
nouveau champ, le champ "Nom" (déjà existant, "ℹ️ Informations")
@@ -363,30 +441,38 @@ def test_render_quiz_box_classique_uses_the_object_name_field_as_its_title(tmp_g
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_name(slug, obj_id, "Formation Sécurité Incendie")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="classiqueHeaderTitle">Formation Sécurité Incendie</span>' in html
def test_render_quiz_box_classique_falls_back_to_a_generic_title_without_a_name(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_falls_back_to_a_generic_title_without_a_name(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="classiqueHeaderTitle">Quiz</span>' in html
def test_render_quiz_box_classique_shows_aucun_under_temps_when_no_timer_is_configured(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_shows_aucun_under_temps_when_no_timer_is_configured(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<span class="classiqueChipValue quizBoxTimerDisplay" data-quiz-role="timer">Aucun</span>' in html
def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pill_container(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pill_container(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite (répétée) : "le conteneur de temps + aucun
doivent avoir la background mais pas le mot aucun" — UN SEUL fond
(celui de ".classiqueChip", identique pour "Temps" et "Score", avec
@@ -397,15 +483,19 @@ def test_render_quiz_box_classique_timer_and_score_chips_share_the_exact_same_pi
autour du mot "Aucun", jamais autour de "Score : 0"."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"})
obj = screens.get_scene_object(slug, obj_id)
screens.set_scene_object_quiz_config(
slug, obj_id, {"fullscreen": True, "page_template": "classique", "timer_mode": "aucun"}
)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'class="classiqueChip classiqueChip--timer"' in html
assert 'class="classiqueChip classiqueChip--score"' in html
assert 'class="classiqueChipValue quizBoxTimerDisplay"' in html
def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Écran de résultat de fin de quiz (demande explicite : "elle affiche
un conteneur qui reaffiche le contenu du quiz au lieu du resultat" —
fonctionnalité désormais réellement implémentée, voir
@@ -415,12 +505,14 @@ def test_render_quiz_box_classique_has_an_always_empty_result_zone_placeholder(t
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueResultZone" data-classique-role="result"></div>' in html
def test_render_quiz_box_classique_has_an_always_visible_footer_band(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_has_an_always_visible_footer_band(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : "il manque le footer foncé avec le bouton" —
un bandeau de pied de page (couleur footer_bg du panneau "🎨 Style")
reste TOUJOURS visible, même avant toute réponse (le bouton
@@ -429,21 +521,25 @@ def test_render_quiz_box_classique_has_an_always_visible_footer_band(tmp_game_sl
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueFooterBand" aria-hidden="true"></div>' in html
def test_render_quiz_box_classique_has_a_progress_stepper_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_has_a_progress_stepper_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueProgress" data-classique-role="progress"></div>' in html
def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(tmp_game_slug_cleanup):
def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Encart d'explication réservé pour une fonctionnalité FUTURE non
encore implémentée (demande explicite : "tu peut le mettre dans le
modele mais ne fonctionnera pas pour le moment") — présent dans le
@@ -451,12 +547,14 @@ def test_render_quiz_box_classique_ships_an_always_empty_explain_placeholder(tmp
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "classique"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert '<div class="classiqueExplain" data-quiz-role="explain"></div>' in html
def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(tmp_game_slug_cleanup):
def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Le panneau "🎨 Style" (couleurs/police) continue de s'appliquer à
TOUS les modèles — un modèle change la disposition/décoration, jamais
une raison d'ignorer les couleurs déjà choisies par le créateur."""
@@ -464,14 +562,15 @@ def test_quiz_box_style_panel_colors_still_apply_regardless_of_template(tmp_game
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_dialogue_box_style(slug, obj_id, {"text_color": "#123456"})
screens.set_scene_object_quiz_config(slug, obj_id, {"dialog_template": "defaut"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "#123456" in html
# ---------- Payload ----------
def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanup):
def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True, "page_template": "manga"})
@@ -482,7 +581,9 @@ def test_full_game_payload_exposes_quiz_config_for_quiz_box(tmp_game_slug_cleanu
assert box["quiz_config"]["page_template"] == "manga"
def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(tmp_game_slug_cleanup):
def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Bug signalé : "il faut régler la caméra sur plein écran et
désactiver ce réglage quand la boîte à quiz est plein écran" — ce
drapeau permet à templates/play.html de rendre l'ÉCRAN lui-même en
@@ -500,7 +601,9 @@ def test_full_game_payload_exposes_any_quiz_box_fullscreen_per_screen(tmp_game_s
assert scene["any_quiz_box_fullscreen"] is True
def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
@@ -511,7 +614,9 @@ def test_play_route_renders_the_screen_itself_fullscreen_when_a_quiz_box_is_full
assert "width:960px" not in html
def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(client, tmp_game_slug_cleanup):
def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
@@ -524,7 +629,10 @@ def test_play_route_keeps_the_camera_size_without_a_fullscreen_quiz_box(client,
# ---------- Éditeur — panneau propriétés (rendu, pas de logique JS ici) ----------
def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
@@ -537,7 +645,9 @@ def test_scene_edit_view_renders_dialog_template_gallery_when_not_fullscreen(cli
assert f"selectQuizBoxDialogTemplate({obj_id}, '{template}')" in html
def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : les modèles "page de quiz" ne se voient QUE
quand la boîte à quiz est plein écran — jamais les deux galeries en
même temps."""
@@ -551,7 +661,9 @@ def test_scene_edit_view_renders_page_template_gallery_when_fullscreen(client, t
assert f"selectQuizBoxPageTemplate({obj_id}, '{template}')" in html
def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(client, tmp_game_slug_cleanup):
def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="dialogue_box")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
@@ -564,7 +676,10 @@ def test_scene_edit_view_does_not_render_quiz_box_options_for_a_dialogue_box(cli
# occuper tout l'écran", "être visible par défaut", "la notion de caméra
# disparaît" — pas seulement au jeu) ----------
def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(client, tmp_game_slug_cleanup):
def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="quiz_box")
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
@@ -574,11 +689,14 @@ def test_scene_edit_view_marks_a_fullscreen_quiz_box_for_the_editor_preview(clie
# .canvasElement.isQuizFullscreenPreview) doit être posée sur le
# conteneur de CET objet précis.
import re
m = re.search(r'<div class="canvasElement[^"]*"\s+id="el-' + str(obj_id) + '"', html)
assert m and "isQuizFullscreenPreview" in m.group(0)
def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug signalé : "il ne devait plus y avoir de notion de caméra" —
masquer seulement le cadre "🎥 Champ de la caméra" ne suffisait pas,
le CANEVAS restait lui-même dimensionné en pixels fixes
@@ -589,6 +707,7 @@ def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullsc
screens.set_scene_object_quiz_config(slug, obj_id, {"fullscreen": True})
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
import re
m = re.search(r'<div class="canvas" id="canvas" style="([^"]*)"', html)
assert m
style = m.group(1)
@@ -596,17 +715,22 @@ def test_scene_edit_view_canvas_itself_fills_the_frame_when_a_quiz_box_is_fullsc
assert "px" not in style.split(";")[0] and "px" not in style.split(";")[1]
def test_scene_edit_view_canvas_keeps_pixel_size_without_a_fullscreen_quiz_box(client, tmp_game_slug_cleanup):
def test_scene_edit_view_canvas_keeps_pixel_size_without_a_fullscreen_quiz_box(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, kind="quiz_box") # pas plein écran
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
import re
m = re.search(r'<div class="canvas" id="canvas" style="([^"]*)"', html)
assert m
assert "width:960px; height:540px;" in m.group(1)
def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
# Un fond plus grand que la scène nominale, pour que "🎥 Champ de la
# caméra" soit normalement affiché (voir resolve_scene_world_size).
@@ -621,7 +745,9 @@ def test_scene_edit_view_hides_the_camera_frame_when_a_quiz_box_is_fullscreen(cl
assert "sceneCameraFrame" not in html_after
def test_scene_edit_view_keeps_the_camera_frame_when_no_quiz_box_is_fullscreen(client, tmp_game_slug_cleanup):
def test_scene_edit_view_keeps_the_camera_frame_when_no_quiz_box_is_fullscreen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=2000, height=2000)
+33 -12
View File
@@ -7,31 +7,42 @@ rôle, ou change de libellé affiché selon son nom. L'ancien endpoint AJAX
dédié (routes/scenes/scene_collision_rule_objects.py) a été retiré avec
l'onglet "🧩 Collision" séparé — ce fichier teste directement les
fonctions Python plutôt qu'une route HTTP désormais inexistante."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_collision_ajax"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_collision_ajax"
) -> 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 _add_ajax(client, slug, screen_id, **data):
def _add_ajax(client: FlaskClient, slug: str, screen_id: int, **data: Any) -> Any:
return client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data=data, headers={"X-Requested-With": "fetch"},
data=data,
headers={"X-Requested-With": "fetch"},
).get_json()
def _list(slug, screen_id):
def _list(slug: str, screen_id: int) -> Any:
objects = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
return screens.build_collision_rule_objects(objects)
def test_freshly_added_personnage_is_pnj_in_the_list(client, tmp_game_slug_cleanup):
def test_freshly_added_personnage_is_pnj_in_the_list(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
entries = _list(slug, screen_id)
@@ -40,39 +51,49 @@ def test_freshly_added_personnage_is_pnj_in_the_list(client, tmp_game_slug_clean
assert entries[0]["role"] == "pnj"
def test_role_change_to_joueur_removes_it_from_the_list(client, tmp_game_slug_cleanup):
def test_role_change_to_joueur_removes_it_from_the_list(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
assert _list(slug, screen_id) == []
def test_role_change_back_to_pnj_readds_it_to_the_list(client, tmp_game_slug_cleanup):
def test_role_change_back_to_pnj_readds_it_to_the_list(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "joueur"}', content_type="application/json")
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json")
client.post(
f"/game/{slug}/scene-objects/{obj_id}/role", data='{"role": "ennemie"}', content_type="application/json"
)
entries = _list(slug, screen_id)
assert len(entries) == 1
assert entries[0]["role"] == "ennemie"
def test_name_change_is_reflected_in_the_list(client, tmp_game_slug_cleanup):
def test_name_change_is_reflected_in_the_list(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
client.post(f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json")
client.post(
f"/game/{slug}/scene-objects/{obj_id}/name", data='{"name": "Le Sorcier"}', content_type="application/json"
)
entries = _list(slug, screen_id)
assert entries[0]["name"] == "Le Sorcier"
def test_deleted_object_is_removed_from_the_list(client, tmp_game_slug_cleanup):
def test_deleted_object_is_removed_from_the_list(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", headers={"X-Requested-With": "fetch"})
assert _list(slug, screen_id) == []
def test_decor_has_no_role(client, tmp_game_slug_cleanup):
def test_decor_has_no_role(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_ajax(client, slug, screen_id, kind="decor")
entries = _list(slug, screen_id)
+101 -47
View File
@@ -1,21 +1,31 @@
"""Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) —
appelée par routes/screens/screen_edit.py, l'unique moteur d'écran
(jeu_2d)."""
import json
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_scene_edit"
) -> 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_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
def test_jeu2d_game_dispatches_to_scene_editor(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "Objets de cette scène" in html
@@ -23,7 +33,9 @@ def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
assert "Personnages" in html
def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsections(client, tmp_game_slug_cleanup):
def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsections(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : "des sous-menu pour voir, utiliser ou
supprimer des son, image et video", PUIS "un bouton d'import séparé
pour les images de fond et les objets" — "Mes assets" doit séparer
@@ -32,23 +44,26 @@ def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsection
suppression ; le sélecteur de fichier des actions "son"/"vidéo"
(USER_ASSETS_OPTIONS), lui, voit TOUJOURS tout, peu importe le type."""
import auth
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
user_id = sess["user_id"]
fond_id = auth.create_user_asset(user_id, "ciel.png", original_name="ciel.png", source="upload", scene_kind="fond")
decor_id = auth.create_user_asset(user_id, "photo.png", original_name="photo.png", source="upload", scene_kind="decor")
decor_id = auth.create_user_asset(
user_id, "photo.png", original_name="photo.png", source="upload", scene_kind="decor"
)
audio_id = auth.create_user_asset(user_id, "bip.mp3", original_name="bip.mp3", source="upload")
video_id = auth.create_user_asset(user_id, "clip.mp4", original_name="clip.mp4", source="upload")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
m = re.search(r"var USER_ASSETS_OPTIONS = (\[.*?\]);", html)
m = not_none(re.search(r"var USER_ASSETS_OPTIONS = (\[.*?\]);", html))
names = [o["name"] for o in json.loads(m.group(1))]
assert set(names) == {"ciel.png", "photo.png", "bip.mp3", "clip.mp4"}
fond_section = html.split('<summary>Fonds</summary>', 1)[1].split("</details>", 1)[0]
decor_section = html.split('<summary>Décors/objets</summary>', 1)[1].split("</details>", 1)[0]
sons_section = html.split('<summary>Sons</summary>', 1)[1].split("</details>", 1)[0]
videos_section = html.split('<summary>Vidéos</summary>', 1)[1].split("</details>", 1)[0]
fond_section = html.split("<summary>Fonds</summary>", 1)[1].split("</details>", 1)[0]
decor_section = html.split("<summary>Décors/objets</summary>", 1)[1].split("</details>", 1)[0]
sons_section = html.split("<summary>Sons</summary>", 1)[1].split("</details>", 1)[0]
videos_section = html.split("<summary>Vidéos</summary>", 1)[1].split("</details>", 1)[0]
assert "ciel.png" in fond_section and "photo.png" not in fond_section
assert "addSceneObject('fond', null, null, {})".format(fond_id) in fond_section
@@ -62,24 +77,29 @@ def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsection
assert "deleteUserAsset({},".format(asset_id) in html
def test_scene_edit_view_defaults_a_legacy_image_without_scene_kind_to_decor(client, tmp_game_slug_cleanup):
def test_scene_edit_view_defaults_a_legacy_image_without_scene_kind_to_decor(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Une image importée AVANT l'ajout de scene_kind (NULL en base) doit
rester utilisable — comportement inchangé : "decor", jamais perdue
dans un vide entre les deux sous-sections."""
import auth
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
user_id = sess["user_id"]
auth.create_user_asset(user_id, "ancienne.png", original_name="ancienne.png", source="upload")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
decor_section = html.split('<summary>Décors/objets</summary>', 1)[1].split("</details>", 1)[0]
fond_section = html.split('<summary>Fonds</summary>', 1)[1].split("</details>", 1)[0]
decor_section = html.split("<summary>Décors/objets</summary>", 1)[1].split("</details>", 1)[0]
fond_section = html.split("<summary>Fonds</summary>", 1)[1].split("</details>", 1)[0]
assert "ancienne.png" in decor_section
assert "ancienne.png" not in fond_section
def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
def test_scene_edit_view_renders_with_a_personnage_object_selected(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
@@ -87,14 +107,16 @@ def test_scene_edit_view_renders_with_a_personnage_object_selected(client, tmp_g
follow_redirects=False,
)
assert resp.status_code == 302
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert "/static/characters/zombie/idle.png" in html
assert f'data-object-id="{obj_id}"' in html
assert "Personnage Forge" in html
def test_scene_object_geometry_route_persists_pixels(client, tmp_game_slug_cleanup):
def test_scene_object_geometry_route_persists_pixels(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
resp = client.post(
@@ -103,11 +125,11 @@ def test_scene_object_geometry_route_persists_pixels(client, tmp_game_slug_clean
content_type="application/json",
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (111, 222, 48, 96)
def test_scene_object_delete_route(client, tmp_game_slug_cleanup):
def test_scene_object_delete_route(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", follow_redirects=False)
@@ -115,25 +137,31 @@ def test_scene_object_delete_route(client, tmp_game_slug_cleanup):
assert screens.get_scene_object(slug, obj_id) is None
def test_flow_node_targets_scene_object(client, tmp_game_slug_cleanup):
def test_flow_node_targets_scene_object(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "action", "action_type": "jouer_animation_sprite",
"target_object_id": obj_id, "data_value": json.dumps({"animation": "walk", "fps": 8, "loop": True}),
}),
data=json.dumps(
{
"node_type": "action",
"action_type": "jouer_animation_sprite",
"target_object_id": obj_id,
"data_value": json.dumps({"animation": "walk", "fps": 8, "loop": True}),
}
),
content_type="application/json",
)
assert resp.status_code == 200
node_id = resp.get_json()["id"]
node = screens.get_flow_node(slug, node_id)
node = not_none(screens.get_flow_node(slug, node_id))
assert node["target_object_id"] == obj_id
assert node["target_element_id"] is None
def test_held_key_trigger_persists_and_is_in_scene_palette(client, tmp_game_slug_cleanup):
def test_held_key_trigger_persists_and_is_in_scene_palette(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Mouvement continu (touche maintenue) — répond au manque signalé par
l'utilisateur : "clavier" (keydown) ne se déclenche qu'une fois par
appui, insuffisant pour faire avancer un personnage en continu tant
@@ -148,30 +176,38 @@ def test_held_key_trigger_persists_and_is_in_scene_palette(client, tmp_game_slug
content_type="application/json",
)
assert resp.status_code == 200
node = screens.get_flow_node(slug, resp.get_json()["id"])
node = not_none(screens.get_flow_node(slug, resp.get_json()["id"]))
assert node["trigger_event"] == "touche_maintenue"
assert node["trigger_key"] == "ArrowRight"
def test_flow_node_collision_condition_targets_scene_objects(client, tmp_game_slug_cleanup):
def test_flow_node_collision_condition_targets_scene_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_a = screens.add_scene_object(slug, screen_id)
obj_b = screens.add_scene_object(slug, screen_id, kind="decor")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({
"node_type": "condition", "cond_source": "collision",
"cond_element_a": obj_a, "cond_element_b": obj_b,
}),
data=json.dumps(
{
"node_type": "condition",
"cond_source": "collision",
"cond_element_a": obj_a,
"cond_element_b": obj_b,
}
),
content_type="application/json",
)
assert resp.status_code == 200
node = screens.get_flow_node(slug, resp.get_json()["id"])
node = not_none(screens.get_flow_node(slug, resp.get_json()["id"]))
assert node["cond_element_a"] == obj_a
assert node["cond_element_b"] == obj_b
def test_play_payload_renders_scene_objects_for_jeu2d_game(client, tmp_game_slug_cleanup):
def test_play_payload_renders_scene_objects_for_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, forge_character="robot")
payload = screens.full_game_payload(slug)
@@ -181,7 +217,7 @@ def test_play_payload_renders_scene_objects_for_jeu2d_game(client, tmp_game_slug
assert any(payload["personnage_animations"].values())
def test_play_html_renders_fixed_size_scene(client, tmp_game_slug_cleanup):
def test_play_html_renders_fixed_size_scene(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
screens.add_scene_object(slug, screen_id, forge_character="zombie")
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
@@ -190,7 +226,9 @@ def test_play_html_renders_fixed_size_scene(client, tmp_game_slug_cleanup):
assert f"width:{db.game_meta(slug)['name'] and 960}px" in html or "width:960px" in html
def test_render_scene_object_carries_data_anim_target(client, tmp_game_slug_cleanup):
def test_render_scene_object_carries_data_anim_target(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug remonté par l'utilisateur : "les animations ne se jouent pas
dans l'aperçu" — applyAnimationClip() (static/js/play/screens.js) et
animation-timeline.js sélectionnent TOUJOURS leur cible via
@@ -199,11 +237,13 @@ def test_render_scene_object_carries_data_anim_target(client, tmp_game_slug_clea
ou personnalisé) ne trouvait jamais sa cible."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
html = screens.render_scene_object(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(not_none(screens.get_scene_object(slug, obj_id)))
assert f'data-anim-target="{obj_id}"' in html
def test_animation_clip_add_route_accepts_a_scene_object(client, tmp_game_slug_cleanup):
def test_animation_clip_add_route_accepts_a_scene_object(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug remonté par l'utilisateur : _animation_clips.element_id portait
une VRAIE contrainte FK vers _screen_elements — poser un clip de
Timeline sur un objet de scène (_scene_objects, un id space distinct)
@@ -220,7 +260,9 @@ def test_animation_clip_add_route_accepts_a_scene_object(client, tmp_game_slug_c
assert clip["element_id"] == obj_id
def test_deleting_scene_object_cleans_up_its_animation_clips(client, tmp_game_slug_cleanup):
def test_deleting_scene_object_cleans_up_its_animation_clips(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
screens.add_animation_clip(slug, screen_id, obj_id, kind="animate_css", animate_name="fadeIn")
@@ -228,7 +270,9 @@ def test_deleting_scene_object_cleans_up_its_animation_clips(client, tmp_game_sl
assert screens.list_animation_clips(slug, screen_id) == []
def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(client, tmp_game_slug_cleanup):
def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Pas de "fond" plus grand que la scène -> le monde affiché reste
exactement la scène nominale, et le repère "🎥 Champ de la caméra"
(voir static/style.css .sceneCameraFrame) ne s'affiche pas — inutile
@@ -245,7 +289,9 @@ def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(client, tmp_
assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="540"' in html
def test_scene_edit_view_expands_world_for_an_oversized_fond(client, tmp_game_slug_cleanup):
def test_scene_edit_view_expands_world_for_an_oversized_fond(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé (glisser-déposer figé à (0,0) pour un objet plus grand
que la scène, voir static/js/scenes/scene-editor.js) : le canevas de
l'éditeur doit maintenant afficher tout le "fond", pas seulement la
@@ -261,7 +307,9 @@ def test_scene_edit_view_expands_world_for_an_oversized_fond(client, tmp_game_sl
assert 'class="sceneCameraFrame" style="width:960px; height:540px;' in html
def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_game_slug_cleanup):
def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
fond_id = screens.add_scene_object(slug, screen_id, kind="fond")
screens.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080)
@@ -270,23 +318,26 @@ def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(client, tmp_gam
assert "var SCENE_HEIGHT = 1080;" in html
def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup):
""""Caméra (px)" (voir templates/scene_edit.html) : jusqu'ici
def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
""" "Caméra (px)" (voir templates/scene_edit.html) : jusqu'ici
scene_width/scene_height n'étaient fixées qu'à la création de
l'écran, jamais modifiables ensuite."""
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_size"))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
screens.set_scene_size(slug, screen_id, 1920, 1080)
screen = screens.get_screen(slug, screen_id)
screen = not_none(screens.get_screen(slug, screen_id))
assert screen["scene_width"] == 1920
assert screen["scene_height"] == 1080
def test_screen_set_scene_size_route_persists_and_reflects_in_editor(client, tmp_game_slug_cleanup):
def test_screen_set_scene_size_route_persists_and_reflects_in_editor(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-size",
data=json.dumps({"width": 1920, "height": 1080}), content_type="application/json",
data=json.dumps({"width": 1920, "height": 1080}),
content_type="application/json",
)
assert resp.get_json() == {"ok": True}
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
@@ -299,10 +350,13 @@ def test_screen_set_scene_size_route_persists_and_reflects_in_editor(client, tmp
assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="1080"' in html
def test_screen_set_scene_size_route_rejects_invalid_dimensions(client, tmp_game_slug_cleanup):
def test_screen_set_scene_size_route_rejects_invalid_dimensions(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-size",
data=json.dumps({"width": "oops", "height": 540}), content_type="application/json",
data=json.dumps({"width": "oops", "height": 540}),
content_type="application/json",
)
assert resp.status_code == 400
+42 -14
View File
@@ -4,33 +4,42 @@ JSON (posée quand l'appelant envoie X-Requested-With: fetch) remplace le
rechargement complet de page — sans ça, poser un objet "vidait" un instant
l'éditeur de collision/quêtes (tout l'état JS repartait de zéro à chaque
ajout)."""
import io
import struct
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import auth
import screens
def _png_bytes(width, height):
def _png_bytes(width: int, height: int) -> Any:
ihdr = struct.pack(">II", width, height) + b"\x08\x02\x00\x00\x00"
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IHDR" + ihdr + b"\x00\x00\x00\x00"
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_add_ajax"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_add_ajax"
) -> 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 _add_ajax(client, slug, screen_id, **data):
def _add_ajax(client: FlaskClient, slug: str, screen_id: int, **data: Any) -> Any:
return client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data=data, headers={"X-Requested-With": "fetch"},
data=data,
headers={"X-Requested-With": "fetch"},
)
def test_ajax_add_returns_json_instead_of_redirecting(client, tmp_game_slug_cleanup):
def test_ajax_add_returns_json_instead_of_redirecting(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = _add_ajax(client, slug, screen_id, kind="personnage")
assert resp.status_code == 200
@@ -40,7 +49,9 @@ def test_ajax_add_returns_json_instead_of_redirecting(client, tmp_game_slug_clea
assert body["animations"]
def test_ajax_add_with_drop_position_places_the_object_exactly_there(client, tmp_game_slug_cleanup):
def test_ajax_add_with_drop_position_places_the_object_exactly_there(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Vrai glisser-déposer (demande explicite : "n'importe où sur la
scène") — x/y postés remplacent le défaut 100/100."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
@@ -51,7 +62,9 @@ def test_ajax_add_with_drop_position_places_the_object_exactly_there(client, tmp
assert body["y"] == 220
def test_ajax_add_with_drop_position_near_the_edge_is_clamped_into_the_camera(client, tmp_game_slug_cleanup):
def test_ajax_add_with_drop_position_near_the_edge_is_clamped_into_the_camera(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Un dépôt trop proche d'un bord ne doit jamais laisser l'objet
déborder du cadre visible par la caméra une fois sa taille réelle
connue (même esprit que ai/tools.py::_clamp_to_camera pour l'IA)."""
@@ -69,7 +82,10 @@ def test_ajax_add_with_drop_position_near_the_edge_is_clamped_into_the_camera(cl
# toujours sur 128×128, trop petite pour agrandir le "monde" au-delà de
# la caméra nominale 960×540) ----------
def test_ajax_add_fond_ignores_the_drop_position_and_stays_anchored_at_the_origin(client, tmp_game_slug_cleanup):
def test_ajax_add_fond_ignores_the_drop_position_and_stays_anchored_at_the_origin(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé ("décalage") : le vrai glisser-déposer envoie une
position EXACTE même pour un fond — sans ce garde-fou, un fond
déposé ailleurs qu'au coin haut-gauche restait décalé, laissant un
@@ -82,12 +98,19 @@ def test_ajax_add_fond_ignores_the_drop_position_and_stays_anchored_at_the_origi
assert (body["x"], body["y"]) == (0, 0)
def test_ajax_add_fond_from_user_asset_uses_the_real_image_dimensions(client, tmp_game_slug_cleanup):
def test_ajax_add_fond_from_user_asset_uses_the_real_image_dimensions(
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:
user_id = sess["user_id"]
asset_id, _filename = auth.save_user_asset(
user_id, _png_bytes(1920, 1080), ".png", original_name="ciel.png", source="upload", scene_kind="fond",
user_id,
_png_bytes(1920, 1080),
".png",
original_name="ciel.png",
source="upload",
scene_kind="fond",
)
resp = _add_ajax(client, slug, screen_id, kind="fond", asset_id=str(asset_id))
assert resp.status_code == 200
@@ -103,7 +126,10 @@ def test_ajax_add_fond_from_user_asset_uses_the_real_image_dimensions(client, tm
# import — sans ça, l'agrandissement n'était visible qu'après un
# rechargement complet de la page) ----------
def test_geometry_route_returns_the_recomputed_world_size_for_an_enlarged_fond(client, tmp_game_slug_cleanup):
def test_geometry_route_returns_the_recomputed_world_size_for_an_enlarged_fond(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = _add_ajax(client, slug, screen_id, kind="fond")
obj_id = resp.get_json()["id"]
@@ -117,10 +143,12 @@ def test_geometry_route_returns_the_recomputed_world_size_for_an_enlarged_fond(c
assert body["world_height"] >= 1200
def test_non_ajax_add_still_redirects(client, tmp_game_slug_cleanup):
def test_non_ajax_add_still_redirects(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""Secours pour tout appelant non-JS (aucun connu, mais gratuit à garder) —
comportement inchangé : redirection classique, sans le header AJAX."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
)
assert resp.status_code == 302
assert "selected=" in resp.headers["Location"]
+17 -5
View File
@@ -1,20 +1,29 @@
"""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
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import auth
import screens
from tests.conftest import not_none
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_object_asset"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_scene_object_asset"
) -> 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_scene_object_add_with_asset_id_poses_the_owners_image(client, tmp_game_slug_cleanup):
def test_scene_object_add_with_asset_id_poses_the_owners_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:
user_id = sess["user_id"]
@@ -27,11 +36,14 @@ def test_scene_object_add_with_asset_id_poses_the_owners_image(client, tmp_game_
)
assert resp.status_code == 200
body = resp.get_json()
obj = screens.get_scene_object(slug, body["id"])
obj = not_none(screens.get_scene_object(slug, body["id"]))
assert obj["attributes"]["src"] == f"/assets/{user_id}/abc123.png"
auth.delete_user_asset(asset_id, user_id)
def test_scene_object_add_with_asset_id_403s_for_another_accounts_asset(client, user_client, tmp_game_slug_cleanup):
def test_scene_object_add_with_asset_id_403s_for_another_accounts_asset(
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:
other_user_id = sess["user_id"]
+17 -6
View File
@@ -5,24 +5,35 @@ scene-editor.js) : même principe que l'ajout (test_scene_object_add_ajax.py)
ne plus "vider" l'éditeur de collision/quêtes le temps d'un remplacement
de <main> par pjax.js (qui interceptait la soumission du formulaire de
suppression classique)."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_delete_ajax"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_delete_ajax"
) -> 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 _add_ajax(client, slug, screen_id, **data):
def _add_ajax(client: FlaskClient, slug: str, screen_id: int, **data: Any) -> Any:
return client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data=data, headers={"X-Requested-With": "fetch"},
data=data,
headers={"X-Requested-With": "fetch"},
).get_json()
def test_ajax_delete_returns_json_and_removes_the_object(client, tmp_game_slug_cleanup):
def test_ajax_delete_returns_json_and_removes_the_object(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
@@ -35,7 +46,7 @@ def test_ajax_delete_returns_json_and_removes_the_object(client, tmp_game_slug_c
assert screens.get_scene_object(slug, obj_id) is None
def test_ajax_delete_404s_for_missing_object(client, tmp_game_slug_cleanup):
def test_ajax_delete_404s_for_missing_object(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/scene-objects/999999/delete",
@@ -45,7 +56,7 @@ def test_ajax_delete_404s_for_missing_object(client, tmp_game_slug_cleanup):
assert resp.get_json() == {"ok": False}
def test_non_ajax_delete_still_redirects(client, tmp_game_slug_cleanup):
def test_non_ajax_delete_still_redirects(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", follow_redirects=False)
+14 -5
View File
@@ -4,24 +4,33 @@ scenes/scene-editor.js) : SCENE_OBJECT_NAMES_JSON n'est posée qu'une fois
au chargement complet de la page — sans cet endpoint, renommer un
personnage puis ouvrir la modale de dialogue dans la même session ne
faisait jamais apparaître son nouveau nom dans "qui parle"."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_names_ajax"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_names_ajax"
) -> 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 _add_ajax(client, slug, screen_id, **data):
def _add_ajax(client: FlaskClient, slug: str, screen_id: int, **data: Any) -> Any:
return client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data=data, headers={"X-Requested-With": "fetch"},
data=data,
headers={"X-Requested-With": "fetch"},
).get_json()
def test_named_personnage_appears_in_the_list(client, tmp_game_slug_cleanup):
def test_named_personnage_appears_in_the_list(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_ajax(client, slug, screen_id, kind="personnage")["id"]
assert client.get(f"/game/{slug}/scene-object-names").get_json() == []
@@ -29,7 +38,7 @@ def test_named_personnage_appears_in_the_list(client, tmp_game_slug_cleanup):
assert client.get(f"/game/{slug}/scene-object-names").get_json() == ["Zed"]
def test_unnamed_objects_are_excluded(client, tmp_game_slug_cleanup):
def test_unnamed_objects_are_excluded(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_ajax(client, slug, screen_id, kind="personnage")
assert client.get(f"/game/{slug}/scene-object-names").get_json() == []
+62 -37
View File
@@ -5,20 +5,25 @@ réutilise TELLE QUELLE la structure _personnage_data et les fonctions
resolve_personnage_* déjà écrites pour le widget "personnage" de
l'éditeur document (Phase 8) — même bibliothèque Forge, même moteur
d'animation côté client, juste une autre table de stockage."""
from collections.abc import Callable
from typing import Any
import db
import screens
from tests.conftest import not_none
def _create_jeu2d_game(tmp_game_slug_cleanup, name="pytest_scene_test"):
def _create_jeu2d_game(tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_scene_test") -> Any:
slug = tmp_game_slug_cleanup(db.create_game(name))
screen_id = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def test_scene_object_defaults_to_forge_personnage(tmp_game_slug_cleanup):
def test_scene_object_defaults_to_forge_personnage(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["kind"] == "personnage"
assert obj["attributes"]["_personnage_data"]
data = screens.resolve_personnage_data(obj)
@@ -26,21 +31,23 @@ def test_scene_object_defaults_to_forge_personnage(tmp_game_slug_cleanup):
assert data["forge_character"] in screens.SPRITE_LIBRARY
def test_add_scene_object_with_specific_forge_character(tmp_game_slug_cleanup):
def test_add_scene_object_with_specific_forge_character(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="personnage", forge_character="zombie")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_data(obj)["forge_character"] == "zombie"
def test_add_decor_scene_object(tmp_game_slug_cleanup):
def test_add_decor_scene_object(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="decor")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["kind"] == "decor"
def test_add_decor_scene_object_with_an_image_defaults_to_200x200_with_a_150x150_collision_box(tmp_game_slug_cleanup):
def test_add_decor_scene_object_with_an_image_defaults_to_200x200_with_a_150x150_collision_box(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Demande explicite : un objet/décor posé depuis "Mes assets" doit
toujours démarrer à 200×200 (jamais la taille réelle, souvent bien
plus grande, ni le défaut 128×128 du schéma), avec une boîte de
@@ -48,34 +55,40 @@ def test_add_decor_scene_object_with_an_image_defaults_to_200x200_with_a_150x150
silhouette réelle."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/photo.png")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert (obj["width"], obj["height"]) == (200, 200)
collision = screens.resolve_collision_settings(obj)
assert (collision["width"], collision["height"]) == (150, 150)
def test_add_decor_scene_object_with_an_image_ignores_real_image_dimensions(tmp_game_slug_cleanup):
def test_add_decor_scene_object_with_an_image_ignores_real_image_dimensions(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Contrairement à "fond" (voir add_scene_object.py), un "decor" ne
doit JAMAIS être posé à la taille réelle de l'image importée — même
quand elle est connue (image_width/image_height fournis)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(
slug, screen_id, kind="decor", image_url="/assets/1/photo.png",
image_width=5504, image_height=3072,
slug,
screen_id,
kind="decor",
image_url="/assets/1/photo.png",
image_width=5504,
image_height=3072,
)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert (obj["width"], obj["height"]) == (200, 200)
def test_scene_object_geometry_is_pixels_not_percent(tmp_game_slug_cleanup):
def test_scene_object_geometry_is_pixels_not_percent(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
screens.update_scene_object_geometry(slug, obj_id, x=320, y=200, width=128, height=128)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (320, 200, 128, 128)
def test_list_scene_objects_ordered_by_z_index(tmp_game_slug_cleanup):
def test_list_scene_objects_ordered_by_z_index(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
first = screens.add_scene_object(slug, screen_id)
second = screens.add_scene_object(slug, screen_id)
@@ -83,19 +96,19 @@ def test_list_scene_objects_ordered_by_z_index(tmp_game_slug_cleanup):
assert [o["id"] for o in objs] == [first, second]
def test_swap_scene_object_forge_character(tmp_game_slug_cleanup):
def test_swap_scene_object_forge_character(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, forge_character="male-adventurer")
screens.set_scene_object_personnage_data(slug, obj_id, {"source": "forge", "forge_character": "robot"})
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_personnage_data(obj)["forge_character"] == "robot"
def test_render_scene_object_shows_idle_frame_pixel_positioned(tmp_game_slug_cleanup):
def test_render_scene_object_shows_idle_frame_pixel_positioned(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
screens.update_scene_object_geometry(slug, obj_id, x=50, y=60, width=64, height=64)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert "/static/characters/zombie/idle.png" in html
assert "left:50" in html and "top:60" in html
@@ -103,82 +116,94 @@ def test_render_scene_object_shows_idle_frame_pixel_positioned(tmp_game_slug_cle
assert 'data-personnage="1"' in html
def test_render_scene_object_alt_uses_the_authored_name_rgaa(tmp_game_slug_cleanup):
def test_render_scene_object_alt_uses_the_authored_name_rgaa(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""RGAA/WCAG 1.1.1 : le nom donné par l'auteur (panneau "ℹ️
Informations") sert d'alternative textuelle — jamais aucun attribut
alt du tout n'était généré avant."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
screens.set_scene_object_name(slug, obj_id, "Zombie du hangar")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'alt="Zombie du hangar"' in html
def test_render_scene_object_alt_is_empty_when_unnamed_rgaa(tmp_game_slug_cleanup):
def test_render_scene_object_alt_is_empty_when_unnamed_rgaa(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""alt="" (décoratif) plutôt qu'aucun attribut du tout quand l'auteur
n'a pas nommé l'objet."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, forge_character="zombie")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'alt=""' in html
def test_render_scene_object_fond_alt_is_always_empty_rgaa(tmp_game_slug_cleanup):
def test_render_scene_object_fond_alt_is_always_empty_rgaa(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
"""Un "fond" est toujours décoratif — alt="" même s'il est nommé."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret")
screens.set_scene_object_name(slug, obj_id, "Grand décor de fond")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
html = screens.render_scene_object(obj)
assert 'alt=""' in html
def test_add_scene_object_with_explicit_position_uses_it_instead_of_the_default(tmp_game_slug_cleanup):
def test_add_scene_object_with_explicit_position_uses_it_instead_of_the_default(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Vrai glisser-déposer depuis la galerie (demande explicite :
"n'importe où sur la scène") — x/y explicites remplacent le défaut
100/100 posé par le schéma (voir ensure_scene_schema.py)."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="decor", x=250, y=340)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["x"] == 250
assert obj["y"] == 340
def test_add_scene_object_without_position_still_defaults_to_100_100(tmp_game_slug_cleanup):
def test_add_scene_object_without_position_still_defaults_to_100_100(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="decor")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["x"] == 100
assert obj["y"] == 100
def test_add_scene_object_fond_with_explicit_position_overrides_the_top_left_default(tmp_game_slug_cleanup):
def test_add_scene_object_fond_with_explicit_position_overrides_the_top_left_default(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
"""Un "fond" reste au coin haut-gauche (0,0) SANS position explicite
(comportement inchangé), mais un glisser-déposer précis doit pouvoir
le positionner ailleurs si le créateur le demande explicitement."""
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret", x=50, y=60)
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["x"] == 50
assert obj["y"] == 60
def test_add_scene_object_fond_without_position_still_defaults_to_top_left(tmp_game_slug_cleanup):
def test_add_scene_object_fond_without_position_still_defaults_to_top_left(
tmp_game_slug_cleanup: Callable[[str], str],
) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret")
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert obj["x"] == 0
assert obj["y"] == 0
def test_delete_scene_object_cleans_up_referencing_flow_nodes(tmp_game_slug_cleanup):
def test_delete_scene_object_cleans_up_referencing_flow_nodes(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
obj_id = screens.add_scene_object(slug, screen_id)
node_id = screens.add_flow_node(
slug, screen_id, node_type="action", action_type="jouer_animation_sprite",
target_object_id=obj_id, data_value='{"animation":"idle","fps":8,"loop":true}',
slug,
screen_id,
node_type="action",
action_type="jouer_animation_sprite",
target_object_id=obj_id,
data_value='{"animation":"idle","fps":8,"loop":true}',
)
screens.delete_scene_object(slug, obj_id)
assert screens.get_flow_node(slug, node_id) is None
+25 -16
View File
@@ -2,17 +2,20 @@
produit" : concept de premier ordre, distinct des variables globales,
préalable aux futurs exports SCORM/xAPI (qui ont besoin d'un signal
"score"/"terminé" propre)."""
import json
from flask.testing import FlaskClient
import db
import screens
def test_get_score_defaults_before_any_write(game):
def test_get_score_defaults_before_any_write(game: str) -> None:
assert db.get_score(game) == {"player_id": db.PLAYER_SHARED, "score": 0, "status": "non_commence"}
def test_set_score_value_and_status(game):
def test_set_score_value_and_status(game: str) -> None:
db.set_score_value(game, 42)
db.set_status(game, "en_cours")
score = db.get_score(game)
@@ -20,12 +23,12 @@ def test_set_score_value_and_status(game):
assert score["status"] == "en_cours"
def test_set_status_rejects_unknown_value(game):
def test_set_status_rejects_unknown_value(game: str) -> None:
assert db.set_status(game, "n_importe_quoi") is False
assert db.get_score(game)["status"] == "non_commence"
def test_score_is_tracked_per_player(game):
def test_score_is_tracked_per_player(game: str) -> None:
db.set_score_value(game, 5, player_id="joueur-a")
db.set_score_value(game, 99, player_id="joueur-b")
assert db.get_score(game, "joueur-a")["score"] == 5
@@ -33,7 +36,7 @@ def test_score_is_tracked_per_player(game):
assert db.get_score(game)["score"] == 0 # PLAYER_SHARED (aperçu créateur) inchangé
def test_apply_score_action_reuses_data_operations(game):
def test_apply_score_action_reuses_data_operations(game: str) -> None:
db.set_score_value(game, 10)
ok = screens.apply_score_action(game, {"data_operation": "incrementer", "data_value": "5"})
assert ok
@@ -44,21 +47,23 @@ def test_apply_score_action_reuses_data_operations(game):
assert db.get_score(game)["score"] == 100
def test_apply_status_action_writes_status(game):
def test_apply_status_action_writes_status(game: str) -> None:
ok = screens.apply_status_action(game, {"data_value": "termine"})
assert ok
assert db.get_score(game)["status"] == "termine"
def test_apply_status_action_rejects_missing_value(game):
def test_apply_status_action_rejects_missing_value(game: str) -> None:
assert screens.apply_status_action(game, {}) is False
def test_flow_node_run_score_route(client, game):
def test_flow_node_run_score_route(client: FlaskClient, game: str) -> None:
screen_id = screens.create_screen(game, "Accueil")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "modifier_score", "data_operation": "incrementer", "data_value": "7"}),
data=json.dumps(
{"node_type": "action", "action_type": "modifier_score", "data_operation": "incrementer", "data_value": "7"}
),
content_type="application/json",
)
node_id = resp.get_json()["id"]
@@ -67,7 +72,7 @@ def test_flow_node_run_score_route(client, game):
assert db.get_score(game)["score"] == 7
def test_flow_node_run_score_route_rejects_wrong_action_type(client, game):
def test_flow_node_run_score_route_rejects_wrong_action_type(client: FlaskClient, game: str) -> None:
screen_id = screens.create_screen(game, "Accueil")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
@@ -79,7 +84,7 @@ def test_flow_node_run_score_route_rejects_wrong_action_type(client, game):
assert run_resp.status_code == 400
def test_flow_node_run_status_route(client, game):
def test_flow_node_run_status_route(client: FlaskClient, game: str) -> None:
screen_id = screens.create_screen(game, "Accueil")
resp = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
@@ -92,17 +97,19 @@ def test_flow_node_run_status_route(client, game):
assert db.get_score(game)["status"] == "echoue"
def test_game_scoring_view_route_reads_player_score(client, game):
def test_game_scoring_view_route_reads_player_score(client: FlaskClient, game: str) -> None:
db.set_score_value(game, 33, player_id="joueur-x")
db.set_status(game, "reussi", player_id="joueur-x")
resp = client.get(f"/game/{game}/scoring/joueur-x")
assert resp.get_json() == {
"player_id": "joueur-x", "score": 33, "status": "reussi",
"player_id": "joueur-x",
"score": 33,
"status": "reussi",
"updated_at": resp.get_json()["updated_at"],
}
def test_two_players_get_independent_scores(game):
def test_two_players_get_independent_scores(game: str) -> None:
"""Le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé
— même garantie que db/global_vars (voir tests/test_player_state.py),
vérifiée ici directement au niveau db/ (routes publiques retirées)."""
@@ -111,7 +118,9 @@ def test_two_players_get_independent_scores(game):
db.set_score_value(game, 20, player_id="bob")
assert db.get_score(game, "alice") == {
"player_id": "alice", "score": 10, "status": "reussi",
"player_id": "alice",
"score": 10,
"status": "reussi",
"updated_at": db.get_score(game, "alice")["updated_at"],
}
assert db.get_score(game, "bob")["score"] == 20
@@ -119,7 +128,7 @@ def test_two_players_get_independent_scores(game):
assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé
def test_editor_exposes_score_and_status_action_labels(client, game):
def test_editor_exposes_score_and_status_action_labels(client: FlaskClient, game: str) -> None:
screen_id = screens.create_screen(game, "Accueil")
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "Modifier le score" in html
+9 -6
View File
@@ -2,17 +2,20 @@
routes/publish/scoring_settings.py) — détermine le statut SCORM/xAPI
"reussi"/"echoue" en fin de partie, voir static/js/play/
dialogue-box-controller.js::forgeComputeFinalScormStatus."""
from flask.testing import FlaskClient
import db
from tests.test_auth import anon_client # noqa: F401
def test_scoring_settings_get_defaults_to_seventy_percent(client, game):
def test_scoring_settings_get_defaults_to_seventy_percent(client: FlaskClient, game: str) -> None:
resp = client.get(f"/game/{game}/scoring-settings")
assert resp.status_code == 200
assert resp.get_json() == {"success_threshold_percent": 70}
def test_scoring_settings_post_then_get_round_trip(client, game):
def test_scoring_settings_post_then_get_round_trip(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": 50})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "success_threshold_percent": 50}
@@ -21,7 +24,7 @@ def test_scoring_settings_post_then_get_round_trip(client, game):
assert resp.get_json() == {"success_threshold_percent": 50}
def test_scoring_settings_post_clamps_out_of_range_values(client, game):
def test_scoring_settings_post_clamps_out_of_range_values(client: FlaskClient, game: str) -> None:
client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": 150})
assert db.get_success_threshold(game) == 100
@@ -29,15 +32,15 @@ def test_scoring_settings_post_clamps_out_of_range_values(client, game):
assert db.get_success_threshold(game) == 0
def test_scoring_settings_post_rejects_invalid_payload(client, game):
def test_scoring_settings_post_rejects_invalid_payload(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/scoring-settings", json={"success_threshold_percent": "abc"})
assert resp.status_code == 400
def test_scoring_settings_route_is_isolated_like_other_game_routes(anon_client):
def test_scoring_settings_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
_register(anon_client, "scoringsettingsisolation@example.com")
_confirm_2fa(anon_client)
+8 -5
View File
@@ -2,17 +2,20 @@
routes/publish/scorm_version_settings.py) — '1.2' par défaut, '2004' au
choix (sépare completion_status/success_status, voir static/js/play/
offline/scorm2004-api.js)."""
from flask.testing import FlaskClient
import db
from tests.test_auth import anon_client # noqa: F401
def test_scorm_version_get_defaults_to_one_dot_two(client, game):
def test_scorm_version_get_defaults_to_one_dot_two(client: FlaskClient, game: str) -> None:
resp = client.get(f"/game/{game}/scorm-version")
assert resp.status_code == 200
assert resp.get_json() == {"scorm_version": "1.2"}
def test_scorm_version_post_then_get_round_trip(client, game):
def test_scorm_version_post_then_get_round_trip(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/scorm-version", json={"scorm_version": "2004"})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "scorm_version": "2004"}
@@ -21,16 +24,16 @@ def test_scorm_version_post_then_get_round_trip(client, game):
assert resp.get_json() == {"scorm_version": "2004"}
def test_scorm_version_post_rejects_invalid_value(client, game):
def test_scorm_version_post_rejects_invalid_value(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/scorm-version", json={"scorm_version": "3rd-edition"})
assert resp.status_code == 400
assert db.get_scorm_version(game) == "1.2"
def test_scorm_version_route_is_isolated_like_other_game_routes(anon_client):
def test_scorm_version_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
_register(anon_client, "scormversionisolation@example.com")
_confirm_2fa(anon_client)
+212 -88
View File
@@ -2,74 +2,103 @@
règle "déclencheur (affichage) -> action" posée sur un ÉCRAN plutôt que
sur un objet — SANS objet requis, pour de la narration/cinématique dès
l'affichage. Section "Déclencheurs de l'écran" de l'éditeur de scène."""
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_screen_triggers"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_screen_triggers"
) -> 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_freshly_created_screen_has_no_screen_triggers(client, tmp_game_slug_cleanup):
def test_freshly_created_screen_has_no_screen_triggers(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
screen = screens.get_screen(slug, screen_id)
assert screens.resolve_screen_triggers(screen) == []
def test_sanitize_screen_triggers_keeps_an_affichage_trigger():
rules = screens.sanitize_screen_triggers([
{"trigger": "affichage", "action": {"type": "dialogue"}},
])
def test_sanitize_screen_triggers_keeps_an_affichage_trigger() -> None:
rules = screens.sanitize_screen_triggers(
[
{"trigger": "affichage", "action": {"type": "dialogue"}},
]
)
assert rules == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
def test_sanitize_screen_triggers_rejects_triggers_not_allowed_at_screen_level():
def test_sanitize_screen_triggers_rejects_triggers_not_allowed_at_screen_level() -> None:
"""Un déclencheur d'OBJET ("collision"/"clic"/"survol") n'a pas de
sens sans objet — seul "affichage" est autorisé pour un déclencheur
d'écran (voir TRIGGER_TYPES_SCREEN)."""
rules = screens.sanitize_screen_triggers([
{"trigger": "collision", "action": {"type": "dialogue"}},
{"trigger": "clic", "action": {"type": "dialogue"}},
{"trigger": "survol", "action": {"type": "dialogue"}},
])
rules = screens.sanitize_screen_triggers(
[
{"trigger": "collision", "action": {"type": "dialogue"}},
{"trigger": "clic", "action": {"type": "dialogue"}},
{"trigger": "survol", "action": {"type": "dialogue"}},
]
)
assert rules == []
def test_sanitize_screen_triggers_keeps_a_video_action():
rules = screens.sanitize_screen_triggers([{
"trigger": "affichage",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
}])
assert rules == [{
"trigger": "affichage",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
}]
def test_sanitize_screen_triggers_keeps_a_video_action() -> None:
rules = screens.sanitize_screen_triggers(
[
{
"trigger": "affichage",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
}
]
)
assert rules == [
{
"trigger": "affichage",
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
}
]
def test_sanitize_screen_triggers_keeps_an_attendre_then_dialogue_chain():
def test_sanitize_screen_triggers_keeps_an_attendre_then_dialogue_chain() -> None:
"""Cas d'usage demandé : "à l'affichage de l'écran" -> attendre N
secondes -> déclencher un dialogue."""
rules = screens.sanitize_screen_triggers([{
"trigger": "affichage",
"action": {
"type": "attendre", "data_value": "3",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}])
rules = screens.sanitize_screen_triggers(
[
{
"trigger": "affichage",
"action": {
"type": "attendre",
"data_value": "3",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
]
)
assert rules[0]["action"]["type"] == "attendre"
assert rules[0]["action"]["then"]["type"] == "dialogue"
def test_set_screen_triggers_persists_and_resolve_reads_it_back(client, tmp_game_slug_cleanup):
def test_set_screen_triggers_persists_and_resolve_reads_it_back(
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, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
screen = screens.get_screen(slug, screen_id)
assert screens.resolve_screen_triggers(screen) == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
def test_screen_triggers_are_isolated_between_screens(client, tmp_game_slug_cleanup):
def test_screen_triggers_are_isolated_between_screens(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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")
screens.set_screen_triggers(slug, screen_id_1, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
@@ -77,14 +106,24 @@ def test_screen_triggers_are_isolated_between_screens(client, tmp_game_slug_clea
assert screens.resolve_screen_triggers(screen_2) == []
def test_append_action_to_screen_trigger_attaches_a_leaf_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
def test_append_action_to_screen_trigger_attaches_a_leaf_at_the_end_of_the_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, [{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
ok = screens.append_action_to_screen_trigger(
slug, screen_id, "d_1",
slug,
screen_id,
"d_1",
{"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
)
assert ok is True
@@ -93,12 +132,16 @@ def test_append_action_to_screen_trigger_attaches_a_leaf_at_the_end_of_the_chain
assert rules[0]["action"]["then"]["type"] == "variable"
def test_append_action_to_screen_trigger_returns_false_for_an_unknown_screen(client, tmp_game_slug_cleanup):
def test_append_action_to_screen_trigger_returns_false_for_an_unknown_screen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
assert screens.append_action_to_screen_trigger(slug, 999999, "d_1", {"type": "dialogue"}) is False
def test_full_game_payload_exposes_screen_triggers(client, tmp_game_slug_cleanup):
def test_full_game_payload_exposes_screen_triggers(
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, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
payload = screens.full_game_payload(slug)
@@ -106,7 +149,9 @@ def test_full_game_payload_exposes_screen_triggers(client, tmp_game_slug_cleanup
assert scene_screen["screen_triggers"] == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
def test_screen_triggers_get_route_returns_the_saved_rules(client, tmp_game_slug_cleanup):
def test_screen_triggers_get_route_returns_the_saved_rules(
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, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
resp = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
@@ -114,7 +159,9 @@ def test_screen_triggers_get_route_returns_the_saved_rules(client, tmp_game_slug
assert resp.get_json() == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
def test_screen_triggers_post_route_appends_a_rule(client, tmp_game_slug_cleanup):
def test_screen_triggers_post_route_appends_a_rule(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/triggers",
@@ -125,12 +172,18 @@ def test_screen_triggers_post_route_appends_a_rule(client, tmp_game_slug_cleanup
assert screens.resolve_screen_triggers(screen) == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
def test_screen_trigger_delete_one_route_removes_only_the_targeted_rule(client, tmp_game_slug_cleanup):
def test_screen_trigger_delete_one_route_removes_only_the_targeted_rule(
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, [
{"trigger": "affichage", "action": {"type": "dialogue"}},
{"trigger": "affichage", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
])
screens.set_screen_triggers(
slug,
screen_id,
[
{"trigger": "affichage", "action": {"type": "dialogue"}},
{"trigger": "affichage", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
],
)
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/0")
assert resp.status_code == 200
screen = screens.get_screen(slug, screen_id)
@@ -139,13 +192,17 @@ def test_screen_trigger_delete_one_route_removes_only_the_targeted_rule(client,
assert rules[0]["action"]["type"] == "son"
def test_screen_trigger_delete_one_route_404s_for_an_out_of_range_index(client, tmp_game_slug_cleanup):
def test_screen_trigger_delete_one_route_404s_for_an_out_of_range_index(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/0")
assert resp.status_code == 404
def test_screen_triggers_delete_route_clears_all_rules(client, tmp_game_slug_cleanup):
def test_screen_triggers_delete_route_clears_all_rules(
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, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers")
@@ -154,12 +211,25 @@ def test_screen_triggers_delete_route_clears_all_rules(client, tmp_game_slug_cle
assert screens.resolve_screen_triggers(screen) == []
def test_screen_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp_game_slug_cleanup):
def test_screen_triggers_get_route_backfills_and_persists_a_missing_leaf_id(
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, [{
"trigger": "affichage",
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
}
],
)
resp1 = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
leaf_id = resp1.get_json()[0]["action"]["id"]
@@ -175,18 +245,28 @@ def test_screen_triggers_get_route_backfills_and_persists_a_missing_leaf_id(clie
assert append_resp.status_code == 200
def test_screen_triggers_get_route_404s_for_an_unknown_screen(client, tmp_game_slug_cleanup):
def test_screen_triggers_get_route_404s_for_an_unknown_screen(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.get(f"/game/{slug}/screens/999999/triggers")
assert resp.status_code == 404
def test_screen_trigger_dialogue_update_route_edits_the_matching_dialogue(client, tmp_game_slug_cleanup):
def test_screen_trigger_dialogue_update_route_edits_the_matching_dialogue(
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, [{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/triggers/dialogue",
json={"dialogue_id": "d_1", "lines": [{"speaker": "narrateur", "text": "Bienvenue !"}]},
@@ -194,40 +274,68 @@ def test_screen_trigger_dialogue_update_route_edits_the_matching_dialogue(client
assert resp.status_code == 200
screen = screens.get_screen(slug, screen_id)
rules = screens.resolve_screen_triggers(screen)
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "narrateur", "text": "Bienvenue !"}]
assert rules[0]["action"]["dialogue"]["lines"] == [
{"type": "dialogue", "speaker": "narrateur", "text": "Bienvenue !"}
]
def test_screen_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(client, tmp_game_slug_cleanup):
def test_screen_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_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, [{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1")
assert resp.status_code == 200
screen = screens.get_screen(slug, screen_id)
assert screens.resolve_screen_triggers(screen) == []
def test_screen_trigger_remove_action_route_404s_for_an_unknown_leaf_id(client, tmp_game_slug_cleanup):
def test_screen_trigger_remove_action_route_404s_for_an_unknown_leaf_id(
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, [{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/action/d_absent")
assert resp.status_code == 404
def test_screen_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, tmp_game_slug_cleanup):
def test_screen_trigger_move_action_route_swaps_a_leaf_up_in_its_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, [{
"trigger": "affichage",
"action": {
"type": "attendre", "id": "a_1", "data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {
"type": "attendre",
"id": "a_1",
"data_value": "2",
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
},
}
],
)
resp = client.post(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1/move", json={"direction": "up"})
assert resp.status_code == 200
screen = screens.get_screen(slug, screen_id)
@@ -236,22 +344,38 @@ def test_screen_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, t
assert action["then"]["type"] == "attendre"
def test_screen_trigger_move_action_route_400s_for_an_invalid_direction(client, tmp_game_slug_cleanup):
def test_screen_trigger_move_action_route_400s_for_an_invalid_direction(
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, [{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1/move", json={"direction": "sideways"})
assert resp.status_code == 400
def test_screen_trigger_append_action_route_attaches_a_leaf(client, tmp_game_slug_cleanup):
def test_screen_trigger_append_action_route_attaches_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, [{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
screens.set_screen_triggers(
slug,
screen_id,
[
{
"trigger": "affichage",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/triggers/append-action",
json={"after_id": "d_1", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
+22 -14
View File
@@ -4,20 +4,24 @@ son" (effet sonore, non bouclé). Comme le reste du graphe de logique côté
client, la lecture audio RÉELLE n'est pas testable sans navigateur (voir
play_js_bundle() — on vérifie que la logique existe dans le JS livré et que
les données persistent, pas qu'elles jouent un son)."""
import json
import re
from typing import Any
from conftest import play_js_bundle
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -27,7 +31,7 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def test_screen_background_music_persists(client, game):
def test_screen_background_music_persists(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/background-music",
@@ -35,11 +39,11 @@ def test_screen_background_music_persists(client, game):
content_type="application/json",
)
assert resp.status_code == 200
screen = screens.get_screen(game, screen_id)
screen = not_none(screens.get_screen(game, screen_id))
assert screen["background_music_url"] == "https://example.com/song.mp3"
def test_screen_background_music_empty_string_clears_it(client, game):
def test_screen_background_music_empty_string_clears_it(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
client.post(
f"/game/{game}/screens/{screen_id}/background-music",
@@ -51,11 +55,11 @@ def test_screen_background_music_empty_string_clears_it(client, game):
data=json.dumps({"url": ""}),
content_type="application/json",
)
screen = screens.get_screen(game, screen_id)
screen = not_none(screens.get_screen(game, screen_id))
assert screen["background_music_url"] is None
def test_play_payload_exposes_background_music_per_screen(client, game):
def test_play_payload_exposes_background_music_per_screen(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
client.post(
f"/game/{game}/screens/{screen_id}/background-music",
@@ -67,25 +71,29 @@ def test_play_payload_exposes_background_music_per_screen(client, game):
assert screen_payload["background_music_url"] == "https://example.com/song.mp3"
def test_sound_action_node_persists_url(client, game):
def test_sound_action_node_persists_url(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="jouer_son",
client,
game,
screen_id,
node_type="action",
action_type="jouer_son",
data_value="https://example.com/effect.wav",
)
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["action_type"] == "jouer_son"
assert node["data_value"] == "https://example.com/effect.wav"
def test_play_page_exposes_sound_runtime(client, game):
def test_play_page_exposes_sound_runtime(client: FlaskClient, game: str) -> None:
js = play_js_bundle()
assert "runScreenBackgroundMusic" in js
assert "'jouer_son'" in js
assert "new Audio" in js
def test_scene_editor_exposes_sound_action(client, game):
def test_scene_editor_exposes_sound_action(client: FlaskClient, game: str) -> None:
"""L'UI de réglage de la musique de fond par écran (screen-bg-music-url)
et le formulaire générique de nœud d'action (nf-action-sound-fields)
n'existaient que dans l'éditeur document, retiré — l'action "Jouer un
+57 -26
View File
@@ -6,29 +6,33 @@ pour le patron des clips). Comme le reste du graphe de logique côté
client, la lecture réelle de l'animation n'est pas testable sans navigateur
(voir play_js_bundle() — on vérifie que la logique existe dans le JS livré
et que les données persistent, pas qu'elles jouent une animation)."""
import json
import os
import re
from typing import Any
from conftest import play_js_bundle
from conftest import play_js_bundle # type: ignore[import-not-found] # resolu par pytest, pas mypy
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
_STATIC_CHARACTERS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "characters"
)
def _create_screen(client, slug, name="Accueil"):
def _create_screen(client: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
def _add_element(client, slug, screen_id, forge_character=None):
def _add_element(client: FlaskClient, slug: str, screen_id: int, forge_character: str | None = None) -> Any:
return screens.add_scene_object(slug, screen_id, forge_character=forge_character)
def _add_flow_node(client, slug, screen_id, **fields):
def _add_flow_node(client: FlaskClient, slug: str, screen_id: int, **fields: Any) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
@@ -38,43 +42,65 @@ def _add_flow_node(client, slug, screen_id, **fields):
return resp.get_json()["id"]
def test_sprite_animation_action_node_persists(client, game):
def test_sprite_animation_action_node_persists(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
data_value = json.dumps({"frames": ["/static/characters/zombie/walk0.png", "/static/characters/zombie/walk1.png"], "fps": 8, "loop": True})
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
target_object_id=el_id, data_value=data_value,
data_value = json.dumps(
{
"frames": ["/static/characters/zombie/walk0.png", "/static/characters/zombie/walk1.png"],
"fps": 8,
"loop": True,
}
)
node = screens.get_flow_node(game, node_id)
node_id = _add_flow_node(
client,
game,
screen_id,
node_type="action",
action_type="jouer_animation_sprite",
target_object_id=el_id,
data_value=data_value,
)
node = not_none(screens.get_flow_node(game, node_id))
assert node["action_type"] == "jouer_animation_sprite"
assert node["target_object_id"] == el_id
assert json.loads(node["data_value"])["fps"] == 8
def test_orientation_action_node_persists(client, game):
def test_orientation_action_node_persists(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="modifier_objet_scene",
target_object_id=el_id, element_property="orientation", element_value="gauche",
client,
game,
screen_id,
node_type="action",
action_type="modifier_objet_scene",
target_object_id=el_id,
element_property="orientation",
element_value="gauche",
)
node = screens.get_flow_node(game, node_id)
node = not_none(screens.get_flow_node(game, node_id))
assert node["element_property"] == "orientation"
assert node["element_value"] == "gauche"
assert node["target_object_id"] == el_id
def test_sprite_timeline_clip_persists(client, game):
def test_sprite_timeline_clip_persists(client: FlaskClient, game: str) -> None:
screen_id = _create_screen(client, game)
el_id = _add_element(client, game, screen_id)
sprite_data = {"frames": ["/static/characters/male-adventurer/idle.png"], "fps": 8}
resp = client.post(
f"/game/{game}/screens/{screen_id}/animations/clips/add",
data=json.dumps({
"element_id": el_id, "kind": "sprite", "custom_keyframes": sprite_data,
"start_time": "0", "iteration_count": "infinite",
}),
data=json.dumps(
{
"element_id": el_id,
"kind": "sprite",
"custom_keyframes": sprite_data,
"start_time": "0",
"iteration_count": "infinite",
}
),
content_type="application/json",
)
assert resp.status_code == 200
@@ -84,17 +110,17 @@ def test_sprite_timeline_clip_persists(client, game):
assert json.loads(clip["custom_keyframes"])["frames"] == ["/static/characters/male-adventurer/idle.png"]
def test_sprite_library_paths_exist_on_disk(client, game):
def test_sprite_library_paths_exist_on_disk(client: FlaskClient, game: str) -> None:
for character in screens.SPRITE_LIBRARY.values():
for frames in character["animations"].values():
assert frames, "chaque animation doit avoir au moins une frame"
for url in frames:
assert url.startswith("/static/characters/")
relative = url[len("/static/characters/"):]
relative = url[len("/static/characters/") :]
assert os.path.isfile(os.path.join(_STATIC_CHARACTERS_DIR, relative)), url
def test_play_page_exposes_sprite_animation_runtime(client, game):
def test_play_page_exposes_sprite_animation_runtime(client: FlaskClient, game: str) -> None:
js = play_js_bundle()
assert "runSpriteAnimation" in js
assert "'jouer_animation_sprite'" in js
@@ -107,7 +133,7 @@ def test_play_page_exposes_sprite_animation_runtime(client, game):
assert "personnage_animations" in js
def test_swapping_forge_character_updates_already_configured_flow_action(client, game):
def test_swapping_forge_character_updates_already_configured_flow_action(client: FlaskClient, game: str) -> None:
"""Régression : un nœud "Jouer une animation" configuré pour un
personnage devait rester bloqué sur les frames de l'ANCIEN personnage
même après avoir changé le personnage Forge de l'élément dans
@@ -118,8 +144,13 @@ def test_swapping_forge_character_updates_already_configured_flow_action(client,
screen_id = _create_screen(client, game)
el_id = screens.add_scene_object(game, screen_id, forge_character="male-adventurer")
_add_flow_node(
client, game, screen_id, node_type="action", action_type="jouer_animation_sprite",
target_object_id=el_id, data_value=json.dumps({"animation": "walk", "fps": 8, "loop": True}),
client,
game,
screen_id,
node_type="action",
action_type="jouer_animation_sprite",
target_object_id=el_id,
data_value=json.dumps({"animation": "walk", "fps": 8, "loop": True}),
)
payload = screens.full_game_payload(game)
+373 -142
View File
@@ -8,29 +8,41 @@ Chaque occurrence de "Déclencher ce dialogue" porte son propre dialogue
ci-dessous). Source de données UNIQUE avec l'éditeur de collision : toute
règle ajoutée/retirée/modifiée ici doit rester visible/cohérente via
screens.resolve_collision_rules directement sur l'objet."""
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import screens
from tests.conftest import not_none
from tests.test_auth import anon_client # noqa: F401
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_triggers"):
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_triggers"
) -> 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 _add_personnage(client, slug, screen_id, role=None):
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
def _add_personnage(client: FlaskClient, slug: str, screen_id: int, role: str | None = None) -> Any:
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
)
assert resp.status_code == 302, resp.data
obj_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
if role:
client.post(f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json")
client.post(
f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json"
)
return obj_id
def _set_rules(slug, object_id, rules):
def _set_rules(slug: str, object_id: int, rules: Any) -> None:
"""Pose des règles directement (sans passer par une route HTTP —
l'ancienne route de sauvegarde, routes/scenes/scene_object_collision_rules.py,
a été retirée avec l'éditeur de collision séparé) pour préparer un
@@ -38,33 +50,39 @@ def _set_rules(slug, object_id, rules):
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules(rules))
def test_mark_completed_is_kept_when_true(client, tmp_game_slug_cleanup):
def test_mark_completed_is_kept_when_true(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": True}}])
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
rules = screens.resolve_collision_rules(obj)
assert rules[0]["action"]["mark_completed"] is True
def test_mark_completed_is_dropped_when_false_or_absent(client, tmp_game_slug_cleanup):
def test_mark_completed_is_dropped_when_false_or_absent(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue", "mark_completed": False}}])
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
rules = screens.resolve_collision_rules(obj)
assert "mark_completed" not in rules[0]["action"]
def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_cleanup):
def test_list_all_triggers_finds_direct_and_nested_rules(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
nested_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, direct_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
_set_rules(slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}])
_set_rules(
slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}]
)
result = screens.list_all_triggers(slug)
ids = [o["object_id"] for o in result]
@@ -76,7 +94,9 @@ def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_c
assert nested_entry["has_interagir"] is True
def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(client, tmp_game_slug_cleanup):
def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Losange à embranchement (maquette validée, lecture verticale) :
Vrai ET Faux peuvent chacun déclencher LEUR PROPRE dialogue,
indépendamment l'un de l'autre — plus de "branche qui mène à telle
@@ -85,14 +105,23 @@ def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(c
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}},
},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "inventaire_cle",
"operateur": "superieur",
"valeur": "0",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}},
},
}
],
)
result = screens.list_all_triggers(slug)
entry = next(o for o in result if o["object_id"] == obj_id)
@@ -101,35 +130,51 @@ def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(c
assert entry["condition"]["si_faux"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}]}
def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(client, tmp_game_slug_cleanup):
def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "0",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, "si_faux": None,
},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "0",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
"si_faux": None,
},
}
],
)
result = screens.list_all_triggers(slug)
entry = next(o for o in result if o["object_id"] == obj_id)
assert entry["condition"]["si_faux"] is None
def test_triggers_delete_removes_all_rules_on_the_object(client, tmp_game_slug_cleanup):
def test_triggers_delete_removes_all_rules_on_the_object(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_collision_rules(obj) == []
def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
def test_triggers_route_add_then_get_then_delete(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
@@ -137,18 +182,23 @@ def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
f"/game/{slug}/triggers",
json={
"object_id": obj_id,
"rule": {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}},
"rule": {
"trigger": "collision",
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
},
},
)
assert resp.status_code == 200
# Même donnée, visible directement via la couche collision (source unique).
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
rules = screens.resolve_collision_rules(obj)
assert rules == [{
"trigger": "collision",
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
}]
assert rules == [
{
"trigger": "collision",
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
}
]
resp = client.get(f"/game/{slug}/triggers")
result = resp.get_json()
@@ -157,95 +207,171 @@ def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_collision_rules(obj) == []
def test_list_all_triggers_surfaces_the_dialogue(client, tmp_game_slug_cleanup):
def test_list_all_triggers_surfaces_the_dialogue(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]},
},
}
],
)
result = screens.list_all_triggers(slug)
assert result[0]["chain"] == [{"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}}]
assert result[0]["chain"] == [
{
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]},
}
]
def test_list_all_triggers_surfaces_a_chain_of_actions(client, tmp_game_slug_cleanup):
def test_list_all_triggers_surfaces_a_chain_of_actions(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
lui ajouter des actions") : dialogue -> then variable, la chaîne
ENTIÈRE est aplatie dans l'ordre d'exécution."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": []},
"then": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
},
}
],
)
result = screens.list_all_triggers(slug)
assert result[0]["chain"] == [
{"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
{"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
{
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
]
def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(client, tmp_game_slug_cleanup):
def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
cond_id = _add_personnage(client, slug, screen_id, role="pnj")
other_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, direct_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True},
}])
_set_rules(slug, cond_id, [{
"trigger": "collision",
"action": {
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True},
"si_faux": None,
},
}])
_set_rules(slug, other_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}},
}])
_set_rules(
slug,
direct_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True},
}
],
)
_set_rules(
slug,
cond_id,
[
{
"trigger": "collision",
"action": {
"type": "condition",
"variable": "score",
"operateur": "egal",
"valeur": "1",
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True},
"si_faux": None,
},
}
],
)
_set_rules(
slug,
other_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}},
}
],
)
ids = screens.list_completable_dialogue_ids(slug)
assert set(ids) == {"d_direct", "d_cond"}
def test_trigger_dialogue_update_route_persists_the_new_lines(client, tmp_game_slug_cleanup):
def test_trigger_dialogue_update_route_persists_the_new_lines(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/dialogue",
json={"dialogue_id": "d_1", "lines": [{"speaker": "pnj", "text": "Trouvé !"}]},
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
rules = screens.resolve_collision_rules(obj)
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}]
def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, tmp_game_slug_cleanup):
def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/dialogue",
@@ -254,7 +380,7 @@ def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, t
assert resp.status_code == 404
def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game):
def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/triggers/999999/dialogue",
json={"dialogue_id": "d_1", "lines": []},
@@ -262,138 +388,239 @@ def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game):
assert resp.status_code == 404
def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/append-action",
json={"after_id": "d_1", "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}},
json={
"after_id": "d_1",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
},
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
rules = screens.resolve_collision_rules(obj)
assert rules[0]["action"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
assert rules[0]["action"]["then"] == {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
}
def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_game_slug_cleanup):
def test_trigger_append_action_route_404s_for_an_unknown_after_id(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/append-action",
json={"after_id": "d_absent", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}},
json={
"after_id": "d_absent",
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"},
},
)
assert resp.status_code == 404
def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then(client, tmp_game_slug_cleanup):
def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
n'importe quel niveau du schéma" — poser "+ Ajouter une action" sur
un bloc qui a déjà une suite INSÈRE la nouvelle action devant cette
suite plutôt que d'échouer (comportement précédent)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {
"type": "dialogue", "dialogue": {"id": "d_1", "lines": []},
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {
"type": "dialogue",
"dialogue": {"id": "d_1", "lines": []},
"then": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
},
}
],
)
resp = client.post(
f"/game/{slug}/triggers/{obj_id}/append-action",
json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}},
json={
"after_id": "d_1",
"action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"},
},
)
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
action = screens.resolve_collision_rules(obj)[0]["action"]
assert action["then"]["target_variable"] == "vies"
assert action["then"]["then"]["target_variable"] == "score"
def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
def test_trigger_append_action_route_404s_for_an_unknown_object(client: FlaskClient, game: str) -> None:
resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}})
assert resp.status_code == 404
def test_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(client, tmp_game_slug_cleanup):
def test_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_1")
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
assert screens.resolve_collision_rules(obj) == []
def test_trigger_remove_action_route_404s_for_an_unknown_leaf_id(client, tmp_game_slug_cleanup):
def test_trigger_remove_action_route_404s_for_an_unknown_leaf_id(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_absent")
assert resp.status_code == 404
def test_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, tmp_game_slug_cleanup):
def test_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"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": []}},
},
}])
_set_rules(
slug,
obj_id,
[
{
"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": []}},
},
}
],
)
resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "up"})
assert resp.status_code == 200
obj = screens.get_scene_object(slug, obj_id)
obj = not_none(screens.get_scene_object(slug, obj_id))
action = screens.resolve_collision_rules(obj)[0]["action"]
assert action["type"] == "dialogue"
assert action["then"]["target_variable"] == "score"
def test_trigger_move_action_route_400s_for_an_invalid_direction(client, tmp_game_slug_cleanup):
def test_trigger_move_action_route_400s_for_an_invalid_direction(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
],
)
resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "sideways"})
assert resp.status_code == 400
def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp_game_slug_cleanup):
def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé : une action "variable" posée sans "id" (par une
session antérieure ou par l'IA) restait à jamais impossible à
adresser via append-action — le premier GET /triggers doit la rendre
adressable, DE FAÇON STABLE (persisté, pas régénéré à chaque appel)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
_set_rules(slug, obj_id, [{
"trigger": "collision",
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
}])
_set_rules(
slug,
obj_id,
[
{
"trigger": "collision",
"action": {
"type": "variable",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
}
],
)
resp1 = client.get(f"/game/{slug}/triggers")
leaf_id = resp1.get_json()[0]["chain"][0]["id"]
@@ -409,7 +636,9 @@ def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp
assert append_resp.status_code == 200
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
def test_objects_catalog_lists_eligible_objects_with_thumbnails(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
_add_personnage(client, slug, screen_id, role="pnj")
@@ -420,10 +649,10 @@ def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game
assert catalog[0]["thumbnail_html"]
def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
def test_triggers_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour ces routes."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
_register(anon_client, "triggersisolation@example.com")
_confirm_2fa(anon_client)
@@ -441,7 +670,9 @@ def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
assert resp.status_code == 403
resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1")
assert resp.status_code == 403
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1/move", json={"direction": "up"})
resp = anon_client.post(
"/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1/move", json={"direction": "up"}
)
assert resp.status_code == 403
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog")
assert resp.status_code == 403
+80 -46
View File
@@ -1,15 +1,19 @@
""""Mes assets" (voir plan Phase 1, §2) — bibliothèque d'images PAR
""" "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 struct
import wave
from typing import Any
from flask.testing import FlaskClient
import auth
def _png_bytes(width, height):
def _png_bytes(width: int, height: int) -> Any:
"""Un PNG minimal — seule la signature + l'en-tête IHDR (largeur/
hauteur) comptent pour auth.image_dimensions, le reste du fichier
n'a pas besoin d'être une image RÉELLEMENT décodable (voir
@@ -19,11 +23,11 @@ def _png_bytes(width, height):
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IHDR" + ihdr + b"\x00\x00\x00\x00"
def _mp4_box(box_type, payload):
def _mp4_box(box_type: Any, payload: Any) -> Any:
return struct.pack(">I4s", len(payload) + 8, box_type) + payload
def _mp4_bytes(seconds, timescale=1000):
def _mp4_bytes(seconds: int, timescale: Any = 1000) -> Any:
"""Un MP4 minimal — juste "ftyp" + "moov/mvhd" (durée/timescale),
suffisant pour que mutagen (auth/validate_video_duration.py) lise sa
durée, sans piste vidéo/audio réelle ni "mdat" (jamais décodé, seule
@@ -31,14 +35,13 @@ def _mp4_bytes(seconds, timescale=1000):
ftyp = _mp4_box(b"ftyp", b"isom" + struct.pack(">I", 0) + b"isom" + b"mp42")
duration = int(seconds * timescale)
mvhd_payload = (
struct.pack(">I", 0) + struct.pack(">II", 0, 0)
+ struct.pack(">II", timescale, duration) + b"\x00" * 80
struct.pack(">I", 0) + struct.pack(">II", 0, 0) + struct.pack(">II", timescale, duration) + b"\x00" * 80
)
moov = _mp4_box(b"moov", _mp4_box(b"mvhd", mvhd_payload))
return ftyp + moov
def _wav_bytes(seconds, framerate=8000):
def _wav_bytes(seconds: int, framerate: Any = 8000) -> Any:
"""Un WAV de silence, durée EXACTE (au sample près) — 8kHz mono 8-bit
pour rester petit même sur 3+ minutes (voir test de la limite de
durée d'un son, auth/validate_audio_duration.py)."""
@@ -51,29 +54,31 @@ def _wav_bytes(seconds, framerate=8000):
return buf.getvalue()
def test_create_and_list_user_asset(client):
def test_create_and_list_user_asset(client: FlaskClient) -> None:
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)
auth.delete_user_asset(asset_id, user_id)
def test_list_user_assets_is_isolated_per_account(client, user_client):
def test_list_user_assets_is_isolated_per_account(client: FlaskClient, user_client: FlaskClient) -> None:
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")
admin_asset_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)
auth.delete_user_asset(admin_asset_id, admin_id)
def test_get_user_asset_returns_none_for_unknown_id():
def test_get_user_asset_returns_none_for_unknown_id() -> None:
assert auth.get_user_asset(999999) is None
def test_delete_user_asset_refuses_when_not_the_owner(client, user_client):
def test_delete_user_asset_refuses_when_not_the_owner(client: FlaskClient, user_client: FlaskClient) -> None:
with client.session_transaction() as sess:
admin_id = sess["user_id"]
with user_client.session_transaction() as sess:
@@ -81,9 +86,10 @@ def test_delete_user_asset_refuses_when_not_the_owner(client, user_client):
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
auth.delete_user_asset(asset_id, admin_id)
def test_upload_user_asset_route_stores_the_file_and_returns_a_url(client):
def test_upload_user_asset_route_stores_the_file_and_returns_a_url(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
@@ -97,9 +103,15 @@ def test_upload_user_asset_route_stores_the_file_and_returns_a_url(client):
dl = client.get(body["url"])
assert dl.status_code == 200
assert dl.data == b"fake-image-bytes"
# libere le descripteur ouvert par send_from_directory avant de le
# supprimer (Windows : os.remove echoue tant qu'il reste ouvert).
dl.close()
with client.session_transaction() as sess:
user_id = sess["user_id"]
auth.delete_user_asset(body["id"], user_id)
def test_upload_user_asset_route_requires_a_file(client):
def test_upload_user_asset_route_requires_a_file(client: FlaskClient) -> None:
resp = client.post("/assets/upload", data={}, content_type="multipart/form-data")
assert resp.status_code == 400
@@ -107,25 +119,28 @@ def test_upload_user_asset_route_requires_a_file(client):
# ---------- scene_kind ("fond"/"decor" — demande explicite : "un bouton
# d'import séparé pour les images de fond et les objets") ----------
def test_create_user_asset_stores_the_scene_kind(client):
def test_create_user_asset_stores_the_scene_kind(client: FlaskClient) -> None:
with client.session_transaction() as sess:
user_id = sess["user_id"]
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="fond")
assets = auth.list_user_assets(user_id)
asset = next(a for a in assets if a["id"] == asset_id)
assert asset["scene_kind"] == "fond"
auth.delete_user_asset(asset_id, user_id)
def test_create_user_asset_scene_kind_defaults_to_none(client):
def test_create_user_asset_scene_kind_defaults_to_none(client: FlaskClient) -> None:
with client.session_transaction() as sess:
user_id = sess["user_id"]
asset_id = auth.create_user_asset(user_id, "ancienne.png", source="upload")
assets = auth.list_user_assets(user_id)
asset = next(a for a in assets if a["id"] == asset_id)
assert asset["scene_kind"] is None
auth.delete_user_asset(asset_id, user_id)
def test_upload_route_stores_the_posted_scene_kind_for_an_image(client):
def test_upload_route_stores_the_posted_scene_kind_for_an_image(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(b"fake-image-bytes"), "ciel.png"), "scene_kind": "fond"},
@@ -137,9 +152,10 @@ def test_upload_route_stores_the_posted_scene_kind_for_an_image(client):
assets = auth.list_user_assets(user_id)
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
assert asset["scene_kind"] == "fond"
auth.delete_user_asset(asset["id"], user_id)
def test_upload_route_defaults_an_image_without_scene_kind_to_decor(client):
def test_upload_route_defaults_an_image_without_scene_kind_to_decor(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
@@ -150,9 +166,10 @@ def test_upload_route_defaults_an_image_without_scene_kind_to_decor(client):
assets = auth.list_user_assets(user_id)
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
assert asset["scene_kind"] == "decor"
auth.delete_user_asset(asset["id"], user_id)
def test_upload_route_ignores_scene_kind_for_a_non_image_file(client):
def test_upload_route_ignores_scene_kind_for_a_non_image_file(client: FlaskClient) -> None:
"""scene_kind n'a de sens que pour une IMAGE (voir
auth/user_asset_kind.py) — un son/une vidéo ne "posent" rien sur la
scène, peu importe ce que le client aurait posté."""
@@ -166,9 +183,10 @@ def test_upload_route_ignores_scene_kind_for_a_non_image_file(client):
assets = auth.list_user_assets(user_id)
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
assert asset["scene_kind"] is None
auth.delete_user_asset(asset["id"], user_id)
def test_serve_user_asset_route_403s_for_another_account(client, user_client):
def test_serve_user_asset_route_403s_for_another_account(client: FlaskClient, user_client: FlaskClient) -> None:
# 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
@@ -186,35 +204,39 @@ def test_serve_user_asset_route_403s_for_another_account(client, user_client):
url = resp.get_json()["url"]
resp2 = user_client.get(url)
assert resp2.status_code == 403
with client.session_transaction() as sess:
admin_id = sess["user_id"]
auth.delete_user_asset(resp.get_json()["id"], admin_id)
# ---------- "Mes assets" — upload d'un son, limité à 3 minutes (demande
# explicite) ----------
def test_user_asset_kind_classifies_by_extension():
def test_user_asset_kind_classifies_by_extension() -> None:
assert auth.user_asset_kind("photo.PNG") == "image"
assert auth.user_asset_kind("bip.mp3") == "audio"
assert auth.user_asset_kind("bip.WAV") == "audio"
assert auth.user_asset_kind("notes.txt") == "other"
def test_validate_audio_duration_accepts_a_short_wav():
def test_validate_audio_duration_accepts_a_short_wav() -> None:
content = _wav_bytes(2)
assert auth.validate_audio_duration(content) is None
def test_validate_audio_duration_rejects_a_wav_over_the_limit():
def test_validate_audio_duration_rejects_a_wav_over_the_limit() -> None:
content = _wav_bytes(200) # > 180s (auth.MAX_AUDIO_SECONDS)
error = auth.validate_audio_duration(content)
assert error is not None
assert "3 minutes" in error
def test_validate_audio_duration_rejects_unrecognized_content():
def test_validate_audio_duration_rejects_unrecognized_content() -> None:
assert auth.validate_audio_duration(b"pas-un-vrai-fichier-audio") is not None
def test_upload_route_accepts_a_short_audio_file(client):
def test_upload_route_accepts_a_short_audio_file(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(_wav_bytes(2)), "bip.wav")},
@@ -225,9 +247,10 @@ def test_upload_route_accepts_a_short_audio_file(client):
user_id = sess["user_id"]
assets = auth.list_user_assets(user_id)
assert any(a["filename"].endswith(".wav") for a in assets)
auth.delete_user_asset(resp.get_json()["id"], user_id)
def test_upload_route_rejects_an_audio_file_over_three_minutes(client):
def test_upload_route_rejects_an_audio_file_over_three_minutes(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(_wav_bytes(200)), "trop-long.wav")},
@@ -244,21 +267,22 @@ def test_upload_route_rejects_an_audio_file_over_three_minutes(client):
# ---------- "Mes assets" — upload d'une vidéo, format MP4 uniquement,
# limité à 5 minutes (demande explicite) ----------
def test_validate_video_duration_accepts_a_short_mp4():
def test_validate_video_duration_accepts_a_short_mp4() -> None:
assert auth.validate_video_duration(_mp4_bytes(30)) is None
def test_validate_video_duration_rejects_an_mp4_over_the_limit():
def test_validate_video_duration_rejects_an_mp4_over_the_limit() -> None:
error = auth.validate_video_duration(_mp4_bytes(400))
assert error is not None
assert "5 minutes" in error
def test_validate_video_duration_rejects_unrecognized_content():
def test_validate_video_duration_rejects_unrecognized_content() -> None:
assert auth.validate_video_duration(b"pas-une-vraie-video") is not None
def test_upload_route_accepts_a_short_mp4_video(client):
def test_upload_route_accepts_a_short_mp4_video(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(_mp4_bytes(30)), "clip.mp4")},
@@ -269,9 +293,10 @@ def test_upload_route_accepts_a_short_mp4_video(client):
user_id = sess["user_id"]
assets = auth.list_user_assets(user_id)
assert any(a["filename"].endswith(".mp4") for a in assets)
auth.delete_user_asset(resp.get_json()["id"], user_id)
def test_upload_route_rejects_an_mp4_video_over_five_minutes(client):
def test_upload_route_rejects_an_mp4_video_over_five_minutes(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(_mp4_bytes(400)), "trop-long.mp4")},
@@ -285,7 +310,7 @@ def test_upload_route_rejects_an_mp4_video_over_five_minutes(client):
assert not any(a["original_name"] == "trop-long.mp4" for a in assets)
def test_upload_route_rejects_a_non_mp4_video_format(client):
def test_upload_route_rejects_a_non_mp4_video_format(client: FlaskClient) -> None:
"""Demande explicite : "une vidéo au format MP4" — webm/mov/avi sont
reconnus comme "vidéo" par auth/user_asset_kind.py (rétrocompatibilité
de l'organisation "Mes assets"), mais plus ACCEPTÉS à l'upload."""
@@ -298,7 +323,7 @@ def test_upload_route_rejects_a_non_mp4_video_format(client):
assert "MP4" in resp.get_json()["error"]
def test_upload_route_rejects_unrecognized_mp4_content(client):
def test_upload_route_rejects_unrecognized_mp4_content(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(b"pas-une-vraie-video"), "faux.mp4")},
@@ -310,7 +335,8 @@ def test_upload_route_rejects_unrecognized_mp4_content(client):
# ---------- Suppression d'un asset (demande explicite : "voir, utiliser
# ou supprimer des son, image et vidéo") ----------
def test_delete_user_asset_route_removes_it(client):
def test_delete_user_asset_route_removes_it(client: FlaskClient) -> None:
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")
@@ -319,12 +345,12 @@ def test_delete_user_asset_route_removes_it(client):
assert auth.get_user_asset(asset_id) is None
def test_delete_user_asset_route_404s_for_an_unknown_id(client):
def test_delete_user_asset_route_404s_for_an_unknown_id(client: FlaskClient) -> None:
resp = client.delete("/assets/999999")
assert resp.status_code == 404
def test_delete_user_asset_route_refuses_another_accounts_asset(client, user_client):
def test_delete_user_asset_route_refuses_another_accounts_asset(client: FlaskClient, user_client: FlaskClient) -> None:
# 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 isole ce test sur le
@@ -338,6 +364,7 @@ def test_delete_user_asset_route_refuses_another_accounts_asset(client, user_cli
resp = user_client.delete(f"/assets/{asset_id}")
assert resp.status_code == 404
assert auth.get_user_asset(asset_id) is not None
auth.delete_user_asset(asset_id, admin_id)
# ---------- Reclasser un asset DÉJÀ importé entre "Fonds" et
@@ -345,33 +372,36 @@ def test_delete_user_asset_route_refuses_another_accounts_asset(client, user_cli
# image importée par le mauvais bouton, ou avant la séparation fond/décor,
# restait bloquée dans la mauvaise section) ----------
def test_update_user_asset_scene_kind_reclassifies_the_asset(client):
def test_update_user_asset_scene_kind_reclassifies_the_asset(client: FlaskClient) -> None:
with client.session_transaction() as sess:
user_id = sess["user_id"]
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
assert auth.update_user_asset_scene_kind(asset_id, user_id, "fond") is True
asset = auth.get_user_asset(asset_id)
assets = auth.list_user_assets(user_id)
assert next(a for a in assets if a["id"] == asset_id)["scene_kind"] == "fond"
auth.delete_user_asset(asset_id, user_id)
def test_update_user_asset_scene_kind_refuses_when_not_the_owner(client, user_client):
def test_update_user_asset_scene_kind_refuses_when_not_the_owner(client: FlaskClient, user_client: FlaskClient) -> None:
with client.session_transaction() as sess:
admin_id = sess["user_id"]
with user_client.session_transaction() as sess:
other_user_id = sess["user_id"]
asset_id = auth.create_user_asset(admin_id, "ciel.png", source="upload", scene_kind="decor")
assert auth.update_user_asset_scene_kind(asset_id, other_user_id, "fond") is False
auth.delete_user_asset(asset_id, admin_id)
def test_update_user_asset_scene_kind_refuses_a_non_image_asset(client):
def test_update_user_asset_scene_kind_refuses_a_non_image_asset(client: FlaskClient) -> None:
with client.session_transaction() as sess:
user_id = sess["user_id"]
asset_id = auth.create_user_asset(user_id, "bip.mp3", source="upload")
assert auth.update_user_asset_scene_kind(asset_id, user_id, "fond") is False
auth.delete_user_asset(asset_id, user_id)
def test_set_scene_kind_route_reclassifies_the_asset(client):
def test_set_scene_kind_route_reclassifies_the_asset(client: FlaskClient) -> None:
with client.session_transaction() as sess:
user_id = sess["user_id"]
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
@@ -379,17 +409,19 @@ def test_set_scene_kind_route_reclassifies_the_asset(client):
assert resp.status_code == 200
assets = auth.list_user_assets(user_id)
assert next(a for a in assets if a["id"] == asset_id)["scene_kind"] == "fond"
auth.delete_user_asset(asset_id, user_id)
def test_set_scene_kind_route_rejects_an_invalid_value(client):
def test_set_scene_kind_route_rejects_an_invalid_value(client: FlaskClient) -> None:
with client.session_transaction() as sess:
user_id = sess["user_id"]
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
resp = client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "autre"})
assert resp.status_code == 400
auth.delete_user_asset(asset_id, user_id)
def test_set_scene_kind_route_404s_for_another_accounts_asset(client, user_client):
def test_set_scene_kind_route_404s_for_another_accounts_asset(client: FlaskClient, user_client: FlaskClient) -> None:
with client.session_transaction() as sess:
admin_id = sess["user_id"]
with user_client.session_transaction() as sess:
@@ -398,21 +430,23 @@ def test_set_scene_kind_route_404s_for_another_accounts_asset(client, user_clien
asset_id = auth.create_user_asset(admin_id, "ciel.png", source="upload", scene_kind="decor")
resp = user_client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "fond"})
assert resp.status_code == 404
auth.delete_user_asset(asset_id, admin_id)
# ---------- Dimensions réelles d'une image importée (bug : "je ne peux
# pas poser un objet hors du champ de la caméra quand j'utilise un fond
# de scène importé" — voir auth/image_dimensions.py) ----------
def test_image_dimensions_reads_a_png_header():
def test_image_dimensions_reads_a_png_header() -> None:
assert auth.image_dimensions(_png_bytes(1920, 1080)) == (1920, 1080)
def test_image_dimensions_returns_none_for_unrecognized_content():
def test_image_dimensions_returns_none_for_unrecognized_content() -> None:
assert auth.image_dimensions(b"pas-une-image") is None
def test_upload_route_rejects_unrecognized_audio_content(client):
def test_upload_route_rejects_unrecognized_audio_content(client: FlaskClient) -> None:
resp = client.post(
"/assets/upload",
data={"file": (io.BytesIO(b"pas-un-vrai-son"), "faux.mp3")},
+34 -21
View File
@@ -2,22 +2,28 @@
routes/publish/xapi_settings.py) — le mot de passe ne doit JAMAIS
ressortir en clair (seulement un booléen `password_set`), et un POST
sans mot de passe ne doit pas écraser celui déjà enregistré."""
from flask.testing import FlaskClient
from tests.test_auth import anon_client # noqa: F401
def test_xapi_settings_get_defaults_to_empty(client, game):
def test_xapi_settings_get_defaults_to_empty(client: FlaskClient, game: str) -> None:
resp = client.get(f"/game/{game}/xapi-settings")
assert resp.status_code == 200
body = resp.get_json()
assert body == {"endpoint": "", "login": "", "password_set": False}
def test_xapi_settings_post_then_get_round_trip(client, game):
resp = client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
})
def test_xapi_settings_post_then_get_round_trip(client: FlaskClient, game: str) -> None:
resp = client.post(
f"/game/{game}/xapi-settings",
json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
},
)
assert resp.status_code == 200
assert resp.get_json() == {"ok": True}
@@ -29,30 +35,37 @@ def test_xapi_settings_post_then_get_round_trip(client, game):
assert "password" not in body
def test_xapi_settings_post_with_blank_password_keeps_the_existing_one(client, game):
client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
})
def test_xapi_settings_post_with_blank_password_keeps_the_existing_one(client: FlaskClient, game: str) -> None:
client.post(
f"/game/{game}/xapi-settings",
json={
"endpoint": "https://lrs.example.com/xapi/",
"login": "monlogin",
"password": "monmotdepasse",
},
)
resp = client.post(f"/game/{game}/xapi-settings", json={
"endpoint": "https://lrs.example.com/xapi/v2/",
"login": "monlogin",
"password": "",
})
resp = client.post(
f"/game/{game}/xapi-settings",
json={
"endpoint": "https://lrs.example.com/xapi/v2/",
"login": "monlogin",
"password": "",
},
)
assert resp.status_code == 200
import db
settings = db.get_xapi_settings(game)
assert settings["endpoint"] == "https://lrs.example.com/xapi/v2/"
assert settings["password"] == "monmotdepasse"
assert settings["password"] == "monmotdepasse" # nosec B105 # noqa: S105 - identifiants factices d'un LRS de test (lrs.example.com), jamais un vrai compte
def test_xapi_settings_route_is_isolated_like_other_game_routes(anon_client):
def test_xapi_settings_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
aucune vérification supplémentaire écrite pour cette route."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
_register(anon_client, "xapisettingsisolation@example.com")
_confirm_2fa(anon_client)