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

363 lines
18 KiB
Python
Raw Permalink 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.
"""Vue de l'éditeur de scène 2D (routes/game/scenes/scene_edit_view.py) —
appelée par routes/game/screens/screen_edit.py, l'unique moteur d'écran
(jeu_2d)."""
import json
import re
from collections.abc import Callable
from typing import Any
from flask.testing import FlaskClient
import db
import game_engine
from tests.conftest import not_none
def _create_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_scene_edit"
) -> 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 = game_engine.create_screen(slug, "Scène 1", kind="jeu_2d")
return slug, screen_id
def test_jeu2d_game_dispatches_to_scene_editor(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "Objets de cette scène" in html
assert "scene-editor.js" in html
assert "Personnages" in html
def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsections(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Demande explicite : "des sous-menu pour voir, utiliser ou
supprimer des son, image et video", PUIS "un bouton d'import séparé
pour les images de fond et les objets" — "Mes assets" doit séparer
Fonds/Décors/Sons/Vidéos (voir auth/user_asset_kind.py,
auth/create_user_asset.py::scene_kind), chacun avec son bouton de
suppression ; le sélecteur de fichier des actions "son"/"vidéo"
(USER_ASSETS_OPTIONS), lui, voit TOUJOURS tout, peu importe le type."""
import auth
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
user_id = sess["user_id"]
fond_id = auth.create_user_asset(user_id, "ciel.png", original_name="ciel.png", source="upload", scene_kind="fond")
decor_id = auth.create_user_asset(
user_id, "photo.png", original_name="photo.png", source="upload", scene_kind="decor"
)
audio_id = auth.create_user_asset(user_id, "bip.mp3", original_name="bip.mp3", source="upload")
video_id = auth.create_user_asset(user_id, "clip.mp4", original_name="clip.mp4", source="upload")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
m = not_none(re.search(r"var USER_ASSETS_OPTIONS = (\[.*?\]);", html))
names = [o["name"] for o in json.loads(m.group(1))]
assert set(names) == {"ciel.png", "photo.png", "bip.mp3", "clip.mp4"}
fond_section = html.split("<summary>Fonds</summary>", 1)[1].split("</details>", 1)[0]
decor_section = html.split("<summary>Décors/objets</summary>", 1)[1].split("</details>", 1)[0]
sons_section = html.split("<summary>Sons</summary>", 1)[1].split("</details>", 1)[0]
videos_section = html.split("<summary>Vidéos</summary>", 1)[1].split("</details>", 1)[0]
assert "ciel.png" in fond_section and "photo.png" not in fond_section
assert "addSceneObject('fond', null, null, {})".format(fond_id) in fond_section
assert "photo.png" in decor_section and "ciel.png" not in decor_section
assert "addSceneObject('decor', null, null, {})".format(decor_id) in decor_section
assert "bip.mp3" in sons_section and "photo.png" not in sons_section
assert "clip.mp4" in videos_section and "photo.png" not in videos_section
# Un bouton de suppression pour chacun (demande explicite).
for asset_id in (fond_id, decor_id, audio_id, video_id):
assert "deleteUserAsset({},".format(asset_id) in html
def test_scene_edit_view_defaults_a_legacy_image_without_scene_kind_to_decor(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Une image importée AVANT l'ajout de scene_kind (NULL en base) doit
rester utilisable — comportement inchangé : "decor", jamais perdue
dans un vide entre les deux sous-sections."""
import auth
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
with client.session_transaction() as sess:
user_id = sess["user_id"]
auth.create_user_asset(user_id, "ancienne.png", original_name="ancienne.png", source="upload")
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
decor_section = html.split("<summary>Décors/objets</summary>", 1)[1].split("</details>", 1)[0]
fond_section = html.split("<summary>Fonds</summary>", 1)[1].split("</details>", 1)[0]
assert "ancienne.png" in decor_section
assert "ancienne.png" not in fond_section
def test_scene_edit_view_renders_with_a_personnage_object_selected(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
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", "forge_character": "zombie"},
follow_redirects=False,
)
assert resp.status_code == 302
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
html = client.get(f"/game/{slug}/screens/{screen_id}/edit?selected={obj_id}").get_data(as_text=True)
assert "/static/game/characters/zombie/idle.png" in html
assert f'data-object-id="{obj_id}"' in html
assert "Personnage Forge" in html
def test_scene_object_geometry_route_persists_pixels(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id)
resp = client.post(
f"/game/{slug}/scene-objects/{obj_id}/geometry",
data=json.dumps({"x": 111, "y": 222, "width": 48, "height": 96}),
content_type="application/json",
)
assert resp.status_code == 200
obj = not_none(game_engine.get_scene_object(slug, obj_id))
assert (obj["x"], obj["y"], obj["width"], obj["height"]) == (111, 222, 48, 96)
def test_scene_object_delete_route(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id)
resp = client.post(f"/game/{slug}/scene-objects/{obj_id}/delete", follow_redirects=False)
assert resp.status_code == 302
assert game_engine.get_scene_object(slug, obj_id) is None
def test_flow_node_targets_scene_object(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id, forge_character="zombie")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(
{
"node_type": "action",
"action_type": "jouer_animation_sprite",
"target_object_id": obj_id,
"data_value": json.dumps({"animation": "walk", "fps": 8, "loop": True}),
}
),
content_type="application/json",
)
assert resp.status_code == 200
node_id = resp.get_json()["id"]
node = not_none(game_engine.get_flow_node(slug, node_id))
assert node["target_object_id"] == obj_id
assert node["target_element_id"] is None
def test_held_key_trigger_persists_and_is_in_scene_palette(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Mouvement continu (touche maintenue) — répond au manque signalé par
l'utilisateur : "clavier" (keydown) ne se déclenche qu'une fois par
appui, insuffisant pour faire avancer un personnage en continu tant
qu'une touche reste enfoncée. Voir runScreenHeldKeyTriggers()
(static/game/js/play/triggers.js) et TRIGGER_EVENTS (game_engine/flow/constants.py)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
assert "touche_maintenue" in dict(game_engine.TRIGGER_EVENTS_2D)
assert "touche_relachee" in dict(game_engine.TRIGGER_EVENTS_2D)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "trigger", "trigger_event": "touche_maintenue", "trigger_key": "ArrowRight"}),
content_type="application/json",
)
assert resp.status_code == 200
node = not_none(game_engine.get_flow_node(slug, resp.get_json()["id"]))
assert node["trigger_event"] == "touche_maintenue"
assert node["trigger_key"] == "ArrowRight"
def test_flow_node_collision_condition_targets_scene_objects(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_a = game_engine.add_scene_object(slug, screen_id)
obj_b = game_engine.add_scene_object(slug, screen_id, kind="decor")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(
{
"node_type": "condition",
"cond_source": "collision",
"cond_element_a": obj_a,
"cond_element_b": obj_b,
}
),
content_type="application/json",
)
assert resp.status_code == 200
node = not_none(game_engine.get_flow_node(slug, resp.get_json()["id"]))
assert node["cond_element_a"] == obj_a
assert node["cond_element_b"] == obj_b
def test_play_payload_renders_scene_objects_for_jeu2d_game(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
game_engine.add_scene_object(slug, screen_id, forge_character="robot")
payload = game_engine.full_game_payload(slug)
scene_payload = next(s for s in payload["screens"] if s["id"] == screen_id)
assert len(scene_payload["elements"]) == 1
assert "/static/game/characters/robot/idle.png" in scene_payload["elements"][0]["rendered_html"]
assert any(payload["personnage_animations"].values())
def test_play_html_renders_fixed_size_scene(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
game_engine.add_scene_object(slug, screen_id, forge_character="zombie")
html = client.get(f"/game/{slug}/play").get_data(as_text=True)
assert "playScene" in html
assert "/static/game/characters/zombie/idle.png" in html
assert f"width:{db.game_meta(slug)['name'] and 960}px" in html or "width:960px" in html
def test_render_scene_object_carries_data_anim_target(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug remonté par l'utilisateur : "les animations ne se jouent pas
dans l'aperçu" — applyAnimationClip() (static/game/js/play/screens.js) et
animation-timeline.js sélectionnent TOUJOURS leur cible via
[data-anim-target=...], jamais data-element-id/data-object-id. Sans cet
attribut sur l'objet de scène, AUCUN clip de Timeline (sprite, Animate.css
ou personnalisé) ne trouvait jamais sa cible."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id)
html = game_engine.render_scene_object(not_none(game_engine.get_scene_object(slug, obj_id)))
assert f'data-anim-target="{obj_id}"' in html
def test_animation_clip_add_route_accepts_a_scene_object(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug remonté par l'utilisateur : _animation_clips.element_id portait
une VRAIE contrainte FK vers _screen_elements — poser un clip de
Timeline sur un objet de scène (_scene_objects, un id space distinct)
faisait échouer l'insertion avec "FOREIGN KEY constraint failed"."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/animations/clips/add",
data=json.dumps({"element_id": obj_id, "kind": "animate_css", "animate_name": "fadeIn"}),
content_type="application/json",
)
assert resp.status_code == 200
clip = resp.get_json()
assert clip["element_id"] == obj_id
def test_deleting_scene_object_cleans_up_its_animation_clips(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
obj_id = game_engine.add_scene_object(slug, screen_id)
game_engine.add_animation_clip(slug, screen_id, obj_id, kind="animate_css", animate_name="fadeIn")
game_engine.delete_scene_object(slug, obj_id)
assert game_engine.list_animation_clips(slug, screen_id) == []
def test_scene_edit_view_world_matches_scene_when_no_oversized_fond(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Pas de "fond" plus grand que la scène -> le monde affiché reste
exactement la scène nominale, et le repère "🎥 Champ de la caméra"
(voir static/style.css .sceneCameraFrame) ne s'affiche pas — inutile
tant que rien ne dépasse."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="canvas" style="width:960px; height:540px;' in html
assert "sceneCameraFrame" not in html
# "Caméra (px)" : TOUJOURS modifiable, même sans fond qui dépasse
# (demande explicite : "l'écran de l'éditeur a une taille fixe, la
# caméra a la même taille, c'est tout" — un réglage direct, pas
# seulement suggéré automatiquement).
assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="960"' in html
assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="540"' in html
def test_scene_edit_view_expands_world_for_an_oversized_fond(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
"""Bug corrigé (glisser-déposer figé à (0,0) pour un objet plus grand
que la scène, voir static/game/js/scenes/scene-editor.js) : le canevas de
l'éditeur doit maintenant afficher tout le "fond", pas seulement la
scène nominale (960×540) qu'il dépasse — sinon la partie qui dépasse
resterait invisible/injoignable à la souris malgré le fix du glisser."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
fond_id = game_engine.add_scene_object(slug, screen_id, kind="fond")
game_engine.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="canvas" style="width:1920px; height:1080px;' in html
# Le repère de la caméra marque toujours les bornes de la scène NOMINALE (960×540),
# même si le canevas affiché est désormais bien plus grand.
assert 'class="sceneCameraFrame" style="width:960px; height:540px;' in html
def test_scene_edit_view_world_bounds_are_used_for_drag_clamping(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
fond_id = game_engine.add_scene_object(slug, screen_id, kind="fond")
game_engine.update_scene_object_geometry(slug, fond_id, x=0, y=0, width=1920, height=1080)
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "var SCENE_WIDTH = 1920;" in html
assert "var SCENE_HEIGHT = 1080;" in html
def test_set_scene_size_updates_screen_dimensions(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
""" "Caméra (px)" (voir templates/game/scene_edit.html) : jusqu'ici
scene_width/scene_height n'étaient fixées qu'à la création de
l'écran, jamais modifiables ensuite."""
slug = tmp_game_slug_cleanup(db.create_game("pytest_scene_size"))
screen_id = game_engine.create_screen(slug, "Scène 1", kind="jeu_2d")
game_engine.set_scene_size(slug, screen_id, 1920, 1080)
screen = not_none(game_engine.get_screen(slug, screen_id))
assert screen["scene_width"] == 1920
assert screen["scene_height"] == 1080
def test_screen_set_scene_size_route_persists_and_reflects_in_editor(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-size",
data=json.dumps({"width": 1920, "height": 1080}),
content_type="application/json",
)
assert resp.get_json() == {"ok": True}
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert 'id="canvas" style="width:1920px; height:1080px;' in html
# La scène nominale == le monde désormais -> plus de repère caméra.
assert "sceneCameraFrame" not in html
# Les champs "Caméra (px)" restent TOUJOURS visibles (pas conditionnels),
# pré-remplis avec la nouvelle taille.
assert 'id="scene-camera-w" class="input is-small" style="width:80px;" min="1" value="1920"' in html
assert 'id="scene-camera-h" class="input is-small" style="width:80px;" min="1" value="1080"' in html
def test_screen_set_scene_size_route_rejects_invalid_dimensions(
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
) -> None:
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-size",
data=json.dumps({"width": "oops", "height": 540}),
content_type="application/json",
)
assert resp.status_code == 400