"""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("Fonds", 1)[1].split("", 1)[0]
decor_section = html.split("Décors/objets", 1)[1].split("", 1)[0]
sons_section = html.split("Sons", 1)[1].split("", 1)[0]
videos_section = html.split("Vidéos", 1)[1].split("", 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("Décors/objets", 1)[1].split("", 1)[0]
fond_section = html.split("Fonds", 1)[1].split("", 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