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:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
+65
-23
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
+834
-399
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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
@@ -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
@@ -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")
|
||||
|
||||
@@ -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,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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user