Files
Forge-Engine/tests/test_scene_edit_view.py
T
williamandClaude Sonnet 5 d5a59bdbd8
Build and deploy / test-python (push) Failing after 6m10s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Fusion des deux moteurs : type d'écran par écran, plus par projet
"document" (écrans %) et "jeu_2d" (scène pixels) devient une propriété
PAR ÉCRAN (_screens.kind, migration automatique idempotente dans
ensure_schema.py, source = l'ancien game_type au niveau projet) plutôt
qu'un choix figé pour tout le jeu — un même projet peut désormais
mélanger écrans classiques et scènes 2D librement.

- routes/screens/screen_edit.py : dispatch vers l'éditeur de scène selon
  screen["kind"] (l'écran demandé), plus game["game_type"].
- screens/payload/full_game_payload.py, templates/play.html,
  static/js/play/screens.js : le rendu jouable (payload, markup, bascule
  du mode plein-écran #playFrame) décide écran par écran, y compris en
  cours de partie (changer d'écran ne recharge pas la page).
- screens/screens_repo/create_screen.py : nouveau paramètre kind.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 06:21:13 +02:00

206 lines
9.9 KiB
Python

"""Vue de l'éditeur de scène 2D (routes/scenes/scene_edit_view.py) —
appelée par routes/screens/screen_edit.py selon screen["kind"] (fusion des
moteurs, voir screens/screens_repo/ensure_schema.py), RÉUTILISE tel quel
le moteur de logique de flow partagé (flow-editor.js/tabs-and-blocks.js/
animation-timeline.js)."""
import json
import re
import db
import screens
from conftest import screen_edit_js_bundle
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_scene_edit"):
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_document_game_still_uses_generic_editor(client, game):
"""Non-régression — critère de succès n°1 du plan : un jeu "document"
existant continue de fonctionner à l'identique."""
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "ÉLÉMENTS DE CET ÉCRAN" in html.upper() or "Éléments de cet écran" in html
assert "scene-editor.js" not in html
def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
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_renders_with_a_personnage_object_selected(client, tmp_game_slug_cleanup):
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(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, tmp_game_slug_cleanup):
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 = 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, tmp_game_slug_cleanup):
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, tmp_game_slug_cleanup):
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 = 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, tmp_game_slug_cleanup):
"""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 = 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, tmp_game_slug_cleanup):
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 = 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, tmp_game_slug_cleanup):
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, tmp_game_slug_cleanup):
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, tmp_game_slug_cleanup):
"""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(screens.get_scene_object(slug, obj_id))
assert f'data-anim-target="{obj_id}"' in html
def test_screen_edit_js_bundle_contains_scene_flow_helpers():
js = screen_edit_js_bundle()
assert "FLOW_TARGETS_OBJECTS" in js
assert "toggleFlowObjectPropertyValue" in js
def test_animation_clip_add_route_accepts_a_scene_object(client, tmp_game_slug_cleanup):
"""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, tmp_game_slug_cleanup):
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_deleting_element_cleans_up_its_animation_clips(client, game):
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
el_resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "texte"},
follow_redirects=False,
)
element_id = int(re.search(r"selected=(\d+)", el_resp.headers["Location"]).group(1))
screens.add_animation_clip(game, screen_id, element_id, kind="animate_css", animate_name="fadeIn")
screens.delete_element(game, element_id)
assert screens.list_animation_clips(game, screen_id) == []