Files
Forge-Engine/tests/test_animal_sprite_library.py
T
williamandClaude Sonnet 5 449c36fd5d
Build and deploy / test-python (push) Failing after 12s
Build and deploy / test-js (push) Successful in 6s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Bibliothèque de sprites animaux CraftPix (1/3) : catalogue, galerie, accès admin
Premier commit d'une fonctionnalité découpée en plusieurs lots (voir le
plan "Bibliothèque de sprites animaux CraftPix") : intègre 14 familles
d'animaux (15 variantes de couleur chacune) comme personnages Forge
sélectionnables, à côté des 6 Kenney existants — réservé au rôle admin,
licence CraftPix oblige (interdiction contractuelle de rendre ces sprites
utilisables par un compte "user" via l'application).

- screens/labels/animal_sprite_library.py (nouveau) : charge un manifest
  JSON généré une fois (voir scripts/generate_animal_sprite_manifest.py,
  commit suivant) et construit ADMIN_SPRITE_LIBRARY, dans le même format
  que l'existant PUBLIC_SPRITE_LIBRARY (screens/labels/sprite_library.py,
  ex-SPRITE_LIBRARY, renommé pour distinguer les deux). screens.SPRITE_LIBRARY
  reste le catalogue FUSIONNÉ (utilisé par resolve_personnage_animations
  pour la résolution runtime, sans filtrage par rôle — voir le constat
  d'exploration : le payload de jeu et /jouer/<slug> ne vérifient déjà
  aucun rôle nulle part).
- screens/labels/sprite_gallery.py (nouveau) : sprite_gallery_families()
  groupe la galerie par famille — un animal n'apparaît qu'une fois (sa
  variante "de base"), ses 15 couleurs se choisissent depuis le panneau
  de propriétés (render_variant_gallery, templates/screen_edit.html),
  répondant à la suggestion de l'utilisateur plutôt que d'encombrer la
  galerie d'ajout de 210 tuiles quasi identiques.
- routes/screens/screen_edit.py, routes/scenes/scene_edit_view.py :
  la galerie passée au template est filtrée par rôle
  (PUBLIC_SPRITE_LIBRARY pour un compte "user", SPRITE_LIBRARY complet
  pour un admin) — même idiome que core/auth_guard.py.
- core/sprite_gate.py (nouveau) + 4 routes d'écriture (element_add,
  element_set_personnage_data, scene_object_add, scene_object_personnage_data) :
  ferme la brèche d'un POST direct qui contournerait la galerie filtrée
  (403 si un compte non-admin tente d'assigner un personnage animal).
- tests/conftest.py : nouvelles fixtures user_client/user_game (compte
  "user" non-admin avec un projet assigné) pour tester le filtrage par
  rôle de bout en bout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:49:05 +02:00

161 lines
7.8 KiB
Python

"""Bibliothèque de sprites animaux CraftPix (screens.ADMIN_SPRITE_LIBRARY,
screens/labels/animal_sprite_library.py) — réservée au rôle admin
(licence CraftPix, voir core/sprite_gate.py et le plan "Bibliothèque de
sprites animaux CraftPix"). Un compte "user" ne doit ni la voir dans la
galerie de l'éditeur, ni pouvoir l'assigner par un POST direct."""
import json
import screens
def test_admin_sprite_library_loaded_from_manifest():
assert len(screens.ADMIN_SPRITE_LIBRARY) > 0
bunny = screens.ADMIN_SPRITE_LIBRARY["animal-bunny-01"]
assert bunny["family"] == "bunny"
assert bunny["animations"]["idle"][0] == "/static/characters/animals/bunny/01/idle0.png"
assert len(bunny["animations"]["idle"]) == 20
def test_sprite_library_is_public_plus_admin_merged():
assert set(screens.SPRITE_LIBRARY) == set(screens.PUBLIC_SPRITE_LIBRARY) | set(screens.ADMIN_SPRITE_LIBRARY)
assert screens.ADMIN_ONLY_CHARACTER_SLUGS == set(screens.ADMIN_SPRITE_LIBRARY)
assert "male-adventurer" not in screens.ADMIN_ONLY_CHARACTER_SLUGS
def test_sprite_gallery_families_shows_one_tile_per_animal():
families = screens.sprite_gallery_families(screens.SPRITE_LIBRARY)
slugs = [slug for slug, _ in families]
assert len(slugs) == len(screens.PUBLIC_SPRITE_LIBRARY) + 14 # 6 Kenney + 14 familles animales
assert "animal-bunny-01" in slugs
assert "animal-bunny-02" not in slugs
def test_document_editor_hides_animal_characters_from_a_user_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
html = user_client.get(f"/game/{user_game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
assert "male-adventurer" in html
def test_document_editor_shows_animal_characters_to_admin(game, client):
resp = client.post(f"/game/{game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
html = client.get(f"/game/{game}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" in html
def _create_jeu2d_game_as(post_client, name, slug_owner_client=None):
resp = post_client.post("/games/new", data={"name": name, "game_type": "jeu_2d"}, follow_redirects=False)
assert resp.status_code == 302
return resp.headers["Location"].rstrip("/").split("/")[-1]
def test_scene_editor_hides_animal_characters_from_a_user_account(client, user_client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_user"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
html = user_client.get(f"/game/{slug}/screens/{screen_id}/edit").get_data(as_text=True)
assert "animal-bunny-01" not in html
def test_scene_object_add_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
resp = user_client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
)
assert resp.status_code == 403
def test_scene_object_add_allows_animal_character_for_admin(client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_scene_admin"))
screen_id = screens.create_screen(slug, "Scène 1")
resp = client.post(
f"/game/{slug}/screens/{screen_id}/scene-objects/add",
data={"kind": "personnage", "forge_character": "animal-bunny-01"},
follow_redirects=False,
)
assert resp.status_code == 302
obj_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
obj = screens.get_scene_object(slug, obj_id)
assert screens.resolve_personnage_data(obj)["forge_character"] == "animal-bunny-01"
def test_scene_object_personnage_swap_rejects_animal_character_for_a_user_account(client, user_client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_animal_swap_gate"))
with user_client.session_transaction() as sess:
import auth
auth.set_project_slug(sess["user_id"], slug)
screen_id = screens.create_screen(slug, "Scène 1")
obj_id = screens.add_scene_object(slug, screen_id)
resp = user_client.post(
f"/game/{slug}/scene-objects/{obj_id}/personnage",
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
content_type="application/json",
)
assert resp.status_code == 403
def test_element_add_rejects_animal_character_for_a_user_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
resp = user_client.post(
f"/game/{user_game}/screens/{screen_id}/elements/add",
data={"widget": "personnage", "forge_character": "animal-bunny-01"},
)
assert resp.status_code == 403
def test_element_set_personnage_data_rejects_animal_character_for_a_user_account(user_game, user_client):
resp = user_client.post(f"/game/{user_game}/screens/new", data={"name": "Accueil"}, follow_redirects=False)
screen_id = resp.headers["Location"].rstrip("/").split("/")[-2]
resp = user_client.post(
f"/game/{user_game}/screens/{screen_id}/elements/add", data={"widget": "personnage"}, follow_redirects=False
)
element_id = int(resp.headers["Location"].rsplit("selected=", 1)[1])
resp = user_client.post(
f"/game/{user_game}/elements/{element_id}/personnage",
data=json.dumps({"source": "forge", "forge_character": "animal-bunny-01"}),
content_type="application/json",
)
assert resp.status_code == 403
def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp_game_slug_cleanup):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_used_characters"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
screens.add_scene_object(slug, screen_id, kind="decor")
used = screens.list_used_forge_characters(slug)
assert used == {"animal-bunny-01"}
def test_build_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_publish_animals"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
from publish import build_package
zip_path, _name = build_package.build_game_zip(slug)
try:
import zipfile
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
animal_paths = {n for n in names if "static/characters/animals/" in n and not n.endswith("manifest.json")}
animals_used = {n.split("static/characters/animals/")[1].split("/")[0] for n in animal_paths}
assert animals_used == {"bunny"}
assert any("static/characters/animals/bunny/01/" in n for n in animal_paths)
assert not any("static/characters/animals/bear/" in n for n in animal_paths)
assert any(n.endswith("static/characters/animals/manifest.json") for n in names)
assert any("static/characters/male-adventurer/" in n for n in names)
finally:
import os
os.remove(zip_path)