Files
Forge-Engine/tests/test_sprite_animation.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

167 lines
6.6 KiB
Python

"""Personnage animé par sprites (poses/images successives) — Phase 7 du
plan d'extension du moteur. Deux entrées équivalentes : une action de flow
"jouer_animation_sprite" (événementiel) et un clip de la Timeline
d'animation de kind "sprite" (déclaratif, voir tests/test_animations.py
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 # 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: FlaskClient, slug: str, name: str = "Accueil") -> Any:
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(not_none(re.search(r"/screens/(\d+)/edit", resp.headers["Location"])).group(1))
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: 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),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
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,
)
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: 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",
)
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: 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",
}
),
content_type="application/json",
)
assert resp.status_code == 200
clip = resp.get_json()
assert clip["kind"] == "sprite"
assert clip["iteration_count"] == "infinite"
assert json.loads(clip["custom_keyframes"])["frames"] == ["/static/characters/male-adventurer/idle.png"]
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/") :]
assert os.path.isfile(os.path.join(_STATIC_CHARACTERS_DIR, relative)), url
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
assert "activeSpriteAnimations" in js
assert "stopAllSpriteAnimations" in js
assert "forgeFlipped" in js
# Phase 8 — résolution à l'exécution depuis le personnage actuellement
# assigné (voir résolution du bug ci-dessous), pas des frames figées.
assert "resolveSpriteFrames" in js
assert "personnage_animations" in js
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
l'éditeur (signalé par l'utilisateur). data_value ne stocke plus que
le NOM de l'animation — resolveSpriteFrames() la résout à l'exécution
depuis gameData.personnage_animations, reconstruit à chaque
chargement de full_game_payload() à partir du personnage ACTUEL."""
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}),
)
payload = screens.full_game_payload(game)
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/male-adventurer/")
client.post(
f"/game/{game}/scene-objects/{el_id}/personnage",
data=json.dumps({"source": "forge", "forge_character": "zombie"}),
content_type="application/json",
)
payload = screens.full_game_payload(game)
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/characters/zombie/")