Files
Forge-Engine/tests/game/test_sprite_animation.py
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

168 lines
6.7 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/game/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 game_engine
from tests.conftest import not_none
_STATIC_CHARACTERS_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "static", "game", "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 game_engine.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/game/characters/zombie/walk0.png", "/static/game/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(game_engine.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(game_engine.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/game/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/game/characters/male-adventurer/idle.png"]
def test_sprite_library_paths_exist_on_disk(client: FlaskClient, game: str) -> None:
for character in game_engine.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/game/characters/")
relative = url[len("/static/game/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 = game_engine.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 = game_engine.full_game_payload(game)
walk_frame = payload["personnage_animations"][str(el_id)]["walk"][0]
assert walk_frame.startswith("/static/game/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 = game_engine.full_game_payload(game)
assert payload["personnage_animations"][str(el_id)]["walk"][0].startswith("/static/game/characters/zombie/")