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>
273 lines
12 KiB
Python
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}
|