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

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/scenes/scene_edit_view.py) —
appelée par routes/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 screens
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 = screens.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/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 = screens.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(screens.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 = screens.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 screens.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 = screens.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(screens.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/js/play/triggers.js) et TRIGGER_EVENTS (screens/flow/constants.py)."""
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
assert "touche_maintenue" in dict(screens.TRIGGER_EVENTS_2D)
assert "touche_relachee" in dict(screens.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(screens.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 = screens.add_scene_object(slug, screen_id)
obj_b = screens.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(screens.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)
screens.add_scene_object(slug, screen_id, forge_character="robot")
payload = screens.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/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)
screens.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/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/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 = screens.add_scene_object(slug, screen_id)
html = screens.render_scene_object(not_none(screens.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 = screens.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 = screens.add_scene_object(slug, screen_id)
screens.add_animation_clip(slug, screen_id, obj_id, kind="animate_css", animate_name="fadeIn")
screens.delete_scene_object(slug, obj_id)
assert screens.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/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 = screens.add_scene_object(slug, screen_id, kind="fond")
screens.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 = screens.add_scene_object(slug, screen_id, kind="fond")
screens.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/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 = screens.create_screen(slug, "Scène 1", kind="jeu_2d")
screens.set_scene_size(slug, screen_id, 1920, 1080)
screen = not_none(screens.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