Files
Forge-Engine/tests/conftest.py
T
williamandClaude Sonnet 5 9ad50c58b8 Audit complet de mise en forme — Image (2e élément du tableau)
Implémente toutes les options manquantes identifiées pour l'élément
Image : dimensionnement/ratio/object-fit, filtres CSS, upload de
fichier (en plus de l'URL), lien/plein écran au clic, chargement
différé, légende, et tous les attributs de boîte partagés déjà créés
pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/
position du bloc).

Système de pages : un support peut désormais avoir 0 page (un nouveau
support démarre vide), suppression de toutes les pages en un clic, et
la pagination automatique insère intelligemment la nouvelle page juste
après celle qui déborde plutôt qu'en toute fin de liste.

Bugs réels trouvés et corrigés en cours de route : le style de bloc
(dont align-self) ciblait l'élément interne au lieu de son enveloppe
(légende/lien) ; une image à sa taille native pouvait déclencher une
pagination infinie ; upload/mise à jour d'attribut ne déclenchaient
jamais le contrôle de débordement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:06:44 +02:00

273 lines
12 KiB
Python

"""
Fixtures pytest partagées.
Les tests utilisent le VRAI client de test Flask (`app.test_client()`) et de
VRAIES bases SQLite créées sous projects/ (comme en production) — rien n'est
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__))))
# Base de comptes (auth/) et clé de session ISOLÉES du poste réel — posé
# AVANT tout import de core.flask_app/auth (chemin résolu à chaque appel,
# voir auth/connection.py, mais réglé ici une bonne fois pour toute la
# session de tests, jamais data/users.db). Sans ça, faire tourner la
# suite de tests écrirait de vrais comptes dans la vraie base de comptes,
# et la logique "premier compte = admin" (auth/create_user.py) serait déjà
# consommée par un run précédent.
_TEST_AUTH_DIR = tempfile.mkdtemp(prefix="forge_test_auth_")
os.environ["FORGE_USERS_DB_PATH"] = os.path.join(_TEST_AUTH_DIR, "users.db")
os.environ["FORGE_SECRET_KEY_PATH"] = os.path.join(_TEST_AUTH_DIR, "secret_key")
# "Mes assets" (voir auth/user_assets_dir.py, db/constants.py::USER_ASSETS_DIR) :
# même raison que FORGE_USERS_DB_PATH ci-dessus — jamais écrire dans le
# vrai user_assets/ du poste pendant les tests.
os.environ["FORGE_USER_ASSETS_DIR"] = os.path.join(_TEST_AUTH_DIR, "user_assets")
import pytest # noqa: E402 - doit venir apres les variables d'environnement ci-dessus
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 —
# load_dotenv() (core.flask_app, importé ci-dessus) les aurait déjà
# chargées dans os.environ à ce stade ; on les retire explicitement pour
# que CHAQUE test parte de "IA non configurée" par défaut. Un test qui
# veut simuler une IA configurée monkeypatche directement
# ai.client.get_client/ai.scenario_client.generate_image_url — jamais via
# une vraie clé, quel que soit le contenu du .env local.
for _ai_env_key in ("ANTHROPIC_API_KEY", "SCENARIO_API_KEY", "SCENARIO_API_SECRET", "SCENARIO_MODEL_ID"):
os.environ.pop(_ai_env_key, None)
# Un seul compte admin pour toute la session de tests : les ~80 routes
# existantes s'attendent (comportement d'avant l'authentification) à un
# accès sans restriction — "admin" est le seul rôle illimité (voir
# core/auth_guard.py). Créé directement en Python (pas via /register) :
# ces tests exercent le RESTE du moteur, pas le parcours d'inscription
# lui-même (qui a ses propres tests dédiés, voir test_auth.py).
_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() -> 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: str) -> str:
slugs.append(slug)
return slug
yield register
for slug in slugs:
game_dir = db.game_dir(slug)
if os.path.isdir(game_dir):
shutil.rmtree(game_dir)
@pytest.fixture
def tmp_support_slug_cleanup() -> Generator[Callable[[str], str], None, None]:
"""Mirroir de tmp_game_slug_cleanup pour un support de formation
(db.create_support(...)) — système de fichiers séparé
(db.supports.support_dir), jamais db.game_dir (qui résoudrait vers le
mauvais dossier pour un slug de support)."""
import db.supports
slugs = []
def register(slug: str) -> str:
slugs.append(slug)
return slug
yield register
for slug in slugs:
support_dir = db.supports.support_dir(slug)
if os.path.isdir(support_dir):
shutil.rmtree(support_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() -> Generator[FlaskClient, None, None]:
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["user_id"] = _TEST_ADMIN_ID
yield c
_TEST_USER_COUNTER = [0]
@pytest.fixture
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) —
pour les tests qui vérifient qu'un rôle non-admin voit/peut moins de
choses qu'un admin (ex. galerie de sprites animaux CraftPix, voir
core/sprite_gate.py)."""
_TEST_USER_COUNTER[0] += 1
email = f"pytest-user-{_TEST_USER_COUNTER[0]}@pytest.local"
user_id = auth.create_user(email, "Test1234!", "Test", "User")
auth.confirm_totp(user_id)
flask_app.config["TESTING"] = True
with flask_app.test_client() as c:
with c.session_transaction() as sess:
sess["user_id"] = user_id
yield c
@pytest.fixture
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) :
créé directement avec `owner_folder=str(user_id)`, jamais via le
client ADMIN (qui produirait un projet appartenant à l'admin, avec
juste `project_slug` réassigné dessus côté "user" — cassait
l'isolation par propriétaire dès qu'elle a cessé d'être une simple
comparaison de chaîne)."""
with user_client.session_transaction() as sess:
user_id = sess["user_id"]
slug = db.create_game("pytest_user_game", owner_folder=str(user_id))
auth.set_project_slug(user_id, slug)
yield slug
if os.path.isdir(db.game_dir(slug)):
db.delete_game(slug)
@pytest.fixture
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)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
yield slug
if os.path.isdir(db.game_dir(slug)):
db.delete_game(slug)
@pytest.fixture
def support(client: FlaskClient) -> Generator[str, None, None]:
"""Crée un support de formation de test frais via /documents/new
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
test, quel que soit son résultat.
Un support neuf n'a plus aucune page par défaut (retour utilisateur
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
lui en crée une par convénience, puisque la quasi-totalité des tests
existants portent sur du contenu et présupposent une première page
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
spécifiquement sur l'état "0 page" créent leur propre support brut
via db.create_support(...) plutôt que d'utiliser cette fixture."""
import db.supports
import document_engine
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
assert resp.status_code == 302
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
document_engine.add_document_page(slug)
yield slug
if os.path.isdir(db.supports.support_dir(slug)):
db.delete_support(slug)
_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "game", "js")
_PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play")
_SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit")
def _js_bundle(directory: str) -> str:
parts = []
for name in sorted(os.listdir(directory)):
path = os.path.join(directory, name)
if os.path.isfile(path) and name.endswith(".js"):
with open(path, encoding="utf-8") as f:
parts.append(f.read())
return "\n".join(parts)
def play_js_bundle() -> str:
"""Concatène tout le JS du moteur jouable (voir le plan de
modularisation — static/game/js/play/, un fichier par responsabilité,
extrait de l'ancien <script> unique de templates/game/play.html). Plusieurs
tests vérifient que "le JS livré au joueur" contient telle fonction/
telle chaîne, sans dépendre d'un navigateur — avant le découpage, ça
revenait à chercher dans le HTML de /game/<slug>/play (tout était
inline) ; maintenant la logique vit dans ces fichiers statiques."""
return _js_bundle(_PLAY_JS_DIR)
def screen_edit_js_bundle() -> str:
"""Même principe que play_js_bundle(), pour l'éditeur (static/game/js/
screen_edit/, extrait de templates/screen_edit.html)."""
return _js_bundle(_SCREEN_EDIT_JS_DIR)
@pytest.fixture
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"},
],
)
return {"slug": game, "definition_id": definition_id}