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
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user