Files
Forge-Engine/tests/test_scene_object_add_ajax.py
T
williamandClaude Sonnet 5 c57420c8c9 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>
2026-09-15 16:06:15 +02:00

155 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Ajout d'un objet de scène en AJAX (voir routes/scenes/scene_object_add.py,
addSceneObject() dans static/js/scenes/scene-editor.js) : la réponse
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 struct
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import auth
import screens
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: 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: 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"},
)
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
body = resp.get_json()
assert body["kind"] == "personnage"
assert "rendered_html" in body and body["rendered_html"]
assert body["animations"]
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)
resp = _add_ajax(client, slug, screen_id, kind="decor", x="300", y="220")
assert resp.status_code == 200
body = resp.get_json()
assert body["x"] == 300
assert body["y"] == 220
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)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = _add_ajax(client, slug, screen_id, kind="decor", x="999999", y="999999")
assert resp.status_code == 200
body = resp.get_json()
assert body["x"] <= 960 - body["width"]
assert body["y"] <= 540 - body["height"]
# ---------- Fond importé ("Mes assets") posé à sa taille RÉELLE — bug :
# "je ne peux pas poser un objet hors du champ de la caméra quand
# j'utilise un fond de scène importé" (sans ça, l'image retombait
# 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: 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
bandeau de canevas vide visible entre le bord de la scène et
l'image."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = _add_ajax(client, slug, screen_id, kind="fond", x="450", y="300")
assert resp.status_code == 200
body = resp.get_json()
assert (body["x"], body["y"]) == (0, 0)
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",
)
resp = _add_ajax(client, slug, screen_id, kind="fond", asset_id=str(asset_id))
assert resp.status_code == 200
body = resp.get_json()
assert body["width"] == 1920
assert body["height"] == 1080
# ---------- Redimensionnement manuel d'un "fond" — la route /geometry
# doit renvoyer le "monde" recalculé pour que scene-editor.js puisse
# rafraîchir SCENE_WIDTH/SCENE_HEIGHT tout de suite (même bug que
# ci-dessus, mais via le redimensionnement à la souris plutôt qu'un
# 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: 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"]
geo = client.post(
f"/game/{slug}/scene-objects/{obj_id}/geometry",
json={"x": 0, "y": 0, "width": 2000, "height": 1200},
)
assert geo.status_code == 200
body = geo.get_json()
assert body["world_width"] >= 2000
assert body["world_height"] >= 1200
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
)
assert resp.status_code == 302
assert "selected=" in resp.headers["Location"]