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
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>
1178 lines
42 KiB
Python
1178 lines
42 KiB
Python
"""Règles de collision (voir game_engine/rendering/collision_rules.py) : une
|
|
règle "déclencheur (collision) -> action (dialogue/interagir/condition)"
|
|
par objet de scène — toujours les AUTRES objets qui réagissent à la
|
|
présence du joueur, jamais l'inverse. Posées depuis l'onglet
|
|
"Déclencheurs" (voir tests/game/test_triggers.py pour le parcours complet côté
|
|
route) — ce fichier teste la couche de validation/lecture elle-même
|
|
(sanitize_collision_rules/resolve_collision_rules), plus l'exposition
|
|
dans full_game_payload."""
|
|
|
|
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_collision_rules"
|
|
) -> 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 _add_personnage(client: FlaskClient, slug: str, screen_id: int, role: str | None = None) -> Any:
|
|
import re
|
|
|
|
resp = client.post(
|
|
f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False
|
|
)
|
|
assert resp.status_code == 302, resp.data
|
|
obj_id = int(not_none(re.search(r"selected=(\d+)", resp.headers["Location"])).group(1))
|
|
if role:
|
|
client.post(
|
|
f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json"
|
|
)
|
|
return obj_id
|
|
|
|
|
|
def test_freshly_placed_object_has_no_collision_rules(
|
|
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
|
) -> None:
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
|
obj = not_none(game_engine.get_scene_object(slug, enemy_id))
|
|
assert game_engine.resolve_collision_rules(obj) == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_dialogue_action(tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
|
tmp_game_slug_cleanup(db.create_game("pytest_collision_sanitize"))
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "collision", "action": {"type": "dialogue"}},
|
|
]
|
|
)
|
|
assert rules == [{"trigger": "collision", "action": {"type": "dialogue"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_an_interagir_action_with_one_level_of_nesting() -> None:
|
|
""" "interagir" affiche "Appuie sur [touche]" puis exécute une SOUS-action
|
|
à l'appui — un seul niveau d'imbrication autorisé (voir
|
|
game_engine/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
|
]
|
|
)
|
|
assert rules == [
|
|
{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}},
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_rejects_interagir_nested_inside_interagir() -> None:
|
|
"""Une imbrication trop profonde (interagir -> interagir -> ...) est
|
|
ignorée plutôt que d'accepter une structure sans fin postée à la main."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "interagir",
|
|
"sub_action": {"type": "interagir", "sub_action": {"type": "dialogue"}},
|
|
},
|
|
},
|
|
]
|
|
)
|
|
assert rules == [] # la règle entière est rejetée
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_condition_action_with_both_branches() -> None:
|
|
""" "condition" (losange à embranchement, maquette validée) : Vrai/Faux
|
|
peuvent chacun valoir None ("ne rien faire") ou une action "dialogue"."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "inventaire_cle",
|
|
"operateur": "superieur",
|
|
"valeur": "0",
|
|
"si_vrai": {"type": "dialogue"},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "inventaire_cle",
|
|
"operateur": "superieur",
|
|
"valeur": "0",
|
|
"si_vrai": {"type": "dialogue"},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_rejects_a_condition_without_variable_or_with_an_unknown_operator() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "",
|
|
"operateur": "superieur",
|
|
"valeur": "0",
|
|
"si_vrai": None,
|
|
"si_faux": None,
|
|
},
|
|
},
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "sorcellerie",
|
|
"valeur": "0",
|
|
"si_vrai": None,
|
|
"si_faux": None,
|
|
},
|
|
},
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_rejects_interagir_nested_inside_a_condition_branch() -> None:
|
|
"""Une branche Vrai/Faux reste une FEUILLE — jamais une nouvelle
|
|
"interagir"/"condition" imbriquée (voir maquette validée : chaque
|
|
branche n'a que "Déclencher ce dialogue" ou "Ne rien faire")."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": {"type": "interagir", "sub_action": {"type": "dialogue"}},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": None,
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_interagir_wrapping_a_condition() -> None:
|
|
"""interagir -> condition -> dialogue (maquette validée : "Interagir avec
|
|
une touche" PUIS un test avant de déclencher le dialogue)."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "interagir",
|
|
"sub_action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "superieur_egal",
|
|
"valeur": "10",
|
|
"si_vrai": {"type": "dialogue"},
|
|
"si_faux": {"type": "dialogue"},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules[0]["action"]["sub_action"]["type"] == "condition"
|
|
assert rules[0]["action"]["sub_action"]["si_vrai"] == {"type": "dialogue"}
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_valid_dialogue_and_mark_completed() -> None:
|
|
"""Chaque occurrence de "Déclencher ce dialogue" porte SON PROPRE
|
|
dialogue ({id, lines}, voir maquette validée : "un dialogue est un
|
|
ensemble de répliques regroupées par un id")."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue",
|
|
"mark_completed": True,
|
|
"dialogue": {
|
|
"id": "d_1",
|
|
"lines": [{"speaker": "pnj", "text": "Salut !"}, {"speaker": "x", "text": " "}],
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue",
|
|
"mark_completed": True,
|
|
"dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]},
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_dialogue_without_a_valid_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue",
|
|
"dialogue": {"id": " ", "lines": [{"speaker": "pnj", "text": "Salut !"}]},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert "dialogue" not in rules[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_dialogue_on_a_condition_branch() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules[0]["action"]["si_vrai"]["dialogue"] == {"id": "d_vrai", "lines": []}
|
|
|
|
|
|
def test_set_dialogue_lines_in_rules_updates_the_matching_dialogue_wherever_it_is() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "interagir",
|
|
"sub_action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": None,
|
|
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
updated = game_engine.set_dialogue_lines_in_rules(rules, "d_1", [{"speaker": "pnj", "text": "Trouvé !"}])
|
|
assert updated is True
|
|
assert rules[0]["action"]["sub_action"]["si_faux"]["dialogue"]["lines"] == [
|
|
{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}
|
|
]
|
|
|
|
|
|
def test_set_dialogue_lines_in_rules_returns_false_for_an_unknown_dialogue_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
assert game_engine.set_dialogue_lines_in_rules(rules, "d_absent", [{"speaker": "pnj", "text": "x"}]) is False
|
|
assert rules[0]["action"]["dialogue"]["lines"] == [] # inchangé
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_variable_action() -> None:
|
|
""" "Modifier une variable" (même vocabulaire que l'action du graphe de
|
|
logique document, voir game_engine/labels/data_operations.py) — utilisable
|
|
partout où une feuille est attendue : ici directement après une
|
|
collision."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "d_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "5",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "d_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "5",
|
|
},
|
|
}
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_variable_action_without_a_target_or_with_an_unknown_operation() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "target_variable": "", "data_operation": "incrementer"},
|
|
},
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "variable", "target_variable": "score", "data_operation": "sorcellerie"},
|
|
},
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_omits_data_value_for_operations_that_need_none() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"target_variable": "actif",
|
|
"data_operation": "basculer_bool",
|
|
"data_value": "peu importe",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert "data_value" not in rules[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_variable_action_as_a_condition_branch() -> None:
|
|
"""Une branche Vrai/Faux peut être une feuille "variable" tout autant
|
|
qu'une feuille "dialogue" (voir maquette validée : chaque branche
|
|
reste une feuille, peu importe laquelle)."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": {
|
|
"type": "variable",
|
|
"target_variable": "vies",
|
|
"data_operation": "decrementer",
|
|
"data_value": "1",
|
|
},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules[0]["action"]["si_vrai"]["type"] == "variable"
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves() -> None:
|
|
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
|
lui ajouter des actions") : dialogue -> then variable -> then dialogue,
|
|
plusieurs actions à la suite sur le MÊME déclencheur."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue",
|
|
"dialogue": {"id": "d_1", "lines": []},
|
|
"then": {
|
|
"type": "variable",
|
|
"id": "d_2",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
action = rules[0]["action"]
|
|
assert action["then"]["type"] == "variable"
|
|
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_3", "lines": []}}
|
|
|
|
|
|
def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long() -> None:
|
|
"""_MAX_THEN_CHAIN borne la chaîne — une chaîne trop longue postée à la
|
|
main est coupée plutôt que rejetée entièrement (même tolérance que le
|
|
reste de ce moteur). Bug corrigé : à 4 (l'ancienne valeur), un
|
|
créateur enchaînant "attendre -> dialogue -> surbrillance -> attendre"
|
|
(4 maillons, un scénario tout à fait normal) voyait déjà TOUT
|
|
5e maillon ajouté ensuite tronqué en silence — voir
|
|
game_engine/rendering/collision_rules.py::_MAX_THEN_CHAIN."""
|
|
chain: dict[str, Any] = {
|
|
"type": "variable",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
}
|
|
node = chain
|
|
for _ in range(50): # largement au-delà de _MAX_THEN_CHAIN (30)
|
|
node["then"] = {
|
|
"type": "variable",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
}
|
|
node = node["then"]
|
|
rules = game_engine.sanitize_collision_rules([{"trigger": "collision", "action": chain}])
|
|
depth = 0
|
|
node = rules[0]["action"]
|
|
while node:
|
|
depth += 1
|
|
node = node.get("then")
|
|
assert depth == 30 # _MAX_THEN_CHAIN, jamais plus
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_then_chain_of_five_leaves_the_bug_that_broke_the_user() -> None:
|
|
"""Reproduction directe du bug signalé : attendre -> dialogue ->
|
|
surbrillance -> attendre -> surbrillance (5 maillons) doit survivre
|
|
intact à la resanitisation qui suit chaque écriture (voir
|
|
append_action_to_screen_trigger/set_then_action_in_rules)."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "attendre",
|
|
"id": "a_1",
|
|
"data_value": "2",
|
|
"then": {
|
|
"type": "dialogue",
|
|
"dialogue": {"id": "d_1", "lines": []},
|
|
"then": {
|
|
"type": "surbrillance",
|
|
"id": "s_1",
|
|
"object_id": 37,
|
|
"valeur": "on",
|
|
"then": {
|
|
"type": "attendre",
|
|
"id": "a_2",
|
|
"data_value": "3",
|
|
"then": {"type": "surbrillance", "id": "s_2", "object_id": 37, "valeur": "off"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
depth = 0
|
|
node = rules[0]["action"]
|
|
while node:
|
|
depth += 1
|
|
node = node.get("then")
|
|
assert depth == 5
|
|
|
|
|
|
def test_set_then_action_in_rules_attaches_a_new_leaf_at_the_end_of_a_dialogue_chain() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
attached = game_engine.set_then_action_in_rules(
|
|
rules,
|
|
"d_1",
|
|
{"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
|
)
|
|
assert attached is True
|
|
assert rules[0]["action"]["then"] == {
|
|
"type": "variable",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
}
|
|
|
|
|
|
def test_set_then_action_in_rules_addresses_a_variable_leaf_by_its_own_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
attached = game_engine.set_then_action_in_rules(
|
|
rules, "v_1", {"type": "dialogue", "dialogue": {"id": "d_2", "lines": []}}
|
|
)
|
|
assert attached is True
|
|
assert rules[0]["action"]["then"]["dialogue"]["id"] == "d_2"
|
|
|
|
|
|
def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
assert (
|
|
game_engine.set_then_action_in_rules(
|
|
rules, "d_absent", {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}
|
|
)
|
|
is False
|
|
)
|
|
|
|
|
|
def test_set_then_action_in_rules_inserts_between_a_leaf_and_its_existing_then() -> None:
|
|
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
|
|
n'importe quel niveau du schéma" — la suite déjà posée (ici "score")
|
|
passe DERRIÈRE la nouvelle action insérée ("vies"), jamais écrasée
|
|
(comportement précédent : refuser purement et simplement)."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "dialogue",
|
|
"dialogue": {"id": "d_1", "lines": []},
|
|
"then": {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
attached = game_engine.set_then_action_in_rules(
|
|
rules,
|
|
"d_1",
|
|
{"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"},
|
|
)
|
|
assert attached is True
|
|
action = rules[0]["action"]
|
|
assert action["then"]["target_variable"] == "vies"
|
|
assert action["then"]["then"] == {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
}
|
|
|
|
|
|
def test_sanitize_collision_rules_accepts_clic_and_survol_triggers() -> None:
|
|
""" "clic"/"survol" — nouveaux déclencheurs sans besoin d'un joueur,
|
|
voir game_engine/rendering/collision_rules.py."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "clic", "action": {"type": "dialogue"}},
|
|
{"trigger": "survol", "action": {"type": "dialogue"}},
|
|
]
|
|
)
|
|
assert [r["trigger"] for r in rules] == ["clic", "survol"]
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_surbrillance_action() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "clic",
|
|
"action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "toggle"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_surbrillance_action_with_an_unknown_valeur_or_object_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": 7, "valeur": "sorcellerie"}},
|
|
{"trigger": "clic", "action": {"type": "surbrillance", "object_id": "pas-un-id", "valeur": "on"}},
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_visibilite_action() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "visibilite", "object_id": 3, "valeur": "masque"},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "masque"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_visibilite_action_with_an_unknown_valeur() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "sorcellerie"}},
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_son_action() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "clic",
|
|
"action": {"type": "son", "asset_url": "/assets/1/bip.mp3"},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [{"trigger": "clic", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_son_action_without_asset_url() -> None:
|
|
rules = game_engine.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}])
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_a_video_action() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "survol",
|
|
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [
|
|
{
|
|
"trigger": "survol",
|
|
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
|
}
|
|
]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_a_video_action_with_an_unknown_mode() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "survol",
|
|
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "sorcellerie"},
|
|
}
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_an_indication_action_with_and_without_duree_ms() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !", "duree_ms": 2000},
|
|
}
|
|
]
|
|
rules_no_duree = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": "Regarde ici !"},
|
|
}
|
|
]
|
|
)
|
|
assert "duree_ms" not in rules_no_duree[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_an_indication_action_without_texte() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "indication", "object_id": 5, "texte": " "},
|
|
}
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_chains_a_video_then_a_dialogue() -> None:
|
|
"""Une "video" reste une FEUILLE chaînable, comme dialogue/variable
|
|
(demande explicite : pouvoir enchaîner plusieurs actions)."""
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "video",
|
|
"id": "v_1",
|
|
"asset_url": "/assets/1/intro.mp4",
|
|
"mode": "plein_ecran",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_sanitize_collision_rules_keeps_an_attendre_action() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "attendre", "data_value": "2.5"},
|
|
}
|
|
]
|
|
)
|
|
assert rules == [{"trigger": "collision", "action": {"type": "attendre", "data_value": "2.5"}}]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_an_attendre_action_with_an_invalid_or_negative_value() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "collision", "action": {"type": "attendre", "data_value": "pas-un-nombre"}},
|
|
{"trigger": "collision", "action": {"type": "attendre", "data_value": "-1"}},
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_sanitize_collision_rules_chains_an_attendre_then_a_dialogue() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "attendre",
|
|
"id": "a_1",
|
|
"data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
assert rules[0]["action"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_set_then_action_in_rules_addresses_a_surbrillance_leaf_by_its_own_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "clic",
|
|
"action": {"type": "surbrillance", "id": "s_1", "object_id": 7, "valeur": "on"},
|
|
}
|
|
]
|
|
)
|
|
attached = game_engine.set_then_action_in_rules(rules, "s_1", {"type": "son", "asset_url": "/assets/1/bip.mp3"})
|
|
assert attached is True
|
|
assert rules[0]["action"]["then"] == {"type": "son", "asset_url": "/assets/1/bip.mp3"}
|
|
|
|
|
|
def test_remove_action_from_rules_removes_the_whole_rule_when_removing_a_single_head_leaf() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
removed = game_engine.remove_action_from_rules(rules, "d_1")
|
|
assert removed is True
|
|
assert rules == []
|
|
|
|
|
|
def test_remove_action_from_rules_promotes_the_then_when_removing_the_head_of_a_chain() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "attendre",
|
|
"id": "a_1",
|
|
"data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
removed = game_engine.remove_action_from_rules(rules, "a_1")
|
|
assert removed is True
|
|
assert rules == [{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}}]
|
|
|
|
|
|
def test_remove_action_from_rules_removes_a_middle_leaf_and_reconnects_the_chain() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
"then": {
|
|
"type": "attendre",
|
|
"id": "a_1",
|
|
"data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
removed = game_engine.remove_action_from_rules(rules, "a_1")
|
|
assert removed is True
|
|
action = rules[0]["action"]
|
|
assert action["type"] == "variable"
|
|
assert action["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_remove_action_from_rules_returns_false_for_an_unknown_leaf_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
assert game_engine.remove_action_from_rules(rules, "d_absent") is False
|
|
assert len(rules) == 1
|
|
|
|
|
|
def test_remove_action_from_rules_removes_a_leaf_inside_a_condition_branch() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "score",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
)
|
|
removed = game_engine.remove_action_from_rules(rules, "d_vrai")
|
|
assert removed is True
|
|
assert rules[0]["action"]["si_vrai"] is None
|
|
|
|
|
|
def test_remove_action_from_rules_removes_interagir_entirely_when_its_only_sub_action_is_removed() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "interagir",
|
|
"sub_action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
removed = game_engine.remove_action_from_rules(rules, "d_1")
|
|
assert removed is True
|
|
assert rules == []
|
|
|
|
|
|
def test_move_action_in_rules_swaps_adjacent_leaves_up_and_down() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
"then": {
|
|
"type": "attendre",
|
|
"id": "a_1",
|
|
"data_value": "2",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
moved = game_engine.move_action_in_rules(rules, "a_1", "up")
|
|
assert moved is True
|
|
action = rules[0]["action"]
|
|
assert action["type"] == "attendre"
|
|
assert action["then"]["type"] == "variable"
|
|
assert action["then"]["then"] == {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}
|
|
|
|
|
|
def test_move_action_in_rules_at_the_start_of_the_chain_moving_up_has_no_effect_but_still_found() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
},
|
|
}
|
|
]
|
|
)
|
|
moved = game_engine.move_action_in_rules(rules, "v_1", "up")
|
|
assert moved is True
|
|
assert rules[0]["action"]["target_variable"] == "score" # inchangé, déjà en tête
|
|
|
|
|
|
def test_move_action_in_rules_returns_false_for_an_unknown_leaf_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
assert game_engine.move_action_in_rules(rules, "d_absent", "up") is False
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_assigns_a_stable_id_to_a_variable_leaf_without_one() -> None:
|
|
"""Bug corrigé : une action "variable" posée sans "id" (ex. par l'IA,
|
|
ce champ restant optionnel dans son schéma, ou une session
|
|
antérieure à cette convention) restait à jamais impossible à
|
|
adresser — "+ Ajouter une action" n'apparaissait jamais après elle."""
|
|
rules: list[dict[str, Any]] = [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
},
|
|
}
|
|
]
|
|
changed = game_engine.backfill_missing_leaf_ids(rules)
|
|
assert changed is True
|
|
assert rules[0]["action"]["id"]
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_reaches_inside_condition_branches_and_then_chains() -> None:
|
|
rules: list[dict[str, Any]] = [
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "condition",
|
|
"variable": "x",
|
|
"operateur": "egal",
|
|
"valeur": "1",
|
|
"si_vrai": {
|
|
"type": "dialogue",
|
|
"dialogue": {"id": "d_1", "lines": []},
|
|
"then": {
|
|
"type": "variable",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
},
|
|
},
|
|
"si_faux": None,
|
|
},
|
|
}
|
|
]
|
|
changed = game_engine.backfill_missing_leaf_ids(rules)
|
|
assert changed is True
|
|
assert rules[0]["action"]["si_vrai"]["then"]["id"]
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_is_a_noop_when_every_leaf_already_has_one() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {
|
|
"type": "variable",
|
|
"id": "v_1",
|
|
"target_variable": "score",
|
|
"data_operation": "incrementer",
|
|
"data_value": "1",
|
|
},
|
|
}
|
|
]
|
|
)
|
|
changed = game_engine.backfill_missing_leaf_ids(rules)
|
|
assert changed is False
|
|
assert rules[0]["action"]["id"] == "v_1"
|
|
|
|
|
|
def test_backfill_missing_leaf_ids_never_touches_a_dialogue_leaf_which_already_has_its_own_id() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{
|
|
"trigger": "collision",
|
|
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
|
}
|
|
]
|
|
)
|
|
changed = game_engine.backfill_missing_leaf_ids(rules)
|
|
assert changed is False
|
|
assert "id" not in rules[0]["action"]
|
|
|
|
|
|
def test_sanitize_collision_rules_drops_malformed_or_unknown_rules() -> None:
|
|
rules = game_engine.sanitize_collision_rules(
|
|
[
|
|
{"trigger": "diagonale", "action": {"type": "dialogue"}},
|
|
{"trigger": "collision", "action": {"type": "sorcellerie"}},
|
|
{"trigger": "perimetre", "action": {"type": "dialogue"}}, # "perimetre" retiré (demande explicite)
|
|
"pas un objet",
|
|
]
|
|
)
|
|
assert rules == []
|
|
|
|
|
|
def test_full_game_payload_excludes_the_player_and_fond_from_collision_rule_objects(
|
|
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
|
) -> None:
|
|
"""Toujours les AUTRES objets qui réagissent à la présence du joueur,
|
|
jamais l'inverse — le joueur lui-même et un fond n'ont pas de champ
|
|
"collision_rules" dans le payload."""
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
# Rôle "pnj" par défaut (voir add_scene_object.py, demande explicite) :
|
|
# "Joueur" est désormais toujours un choix explicite.
|
|
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
|
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
|
|
|
payload = game_engine.full_game_payload(slug)
|
|
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
|
|
player_payload = next(o for o in scene_screen["elements"] if o["id"] == player_id)
|
|
enemy_payload = next(o for o in scene_screen["elements"] if o["id"] == enemy_id)
|
|
assert "collision_rules" not in player_payload
|
|
assert enemy_payload["collision_rules"] == []
|
|
|
|
|
|
def test_build_collision_rule_objects_excludes_the_player(
|
|
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
|
) -> None:
|
|
"""game_engine.build_collision_rule_objects (voir game_engine/rendering/
|
|
trigger_graph.py pour son autre appelant, le listage de tous les
|
|
déclencheurs du jeu) : "Joueur" (le seul rôle exclu) est désormais
|
|
toujours un choix explicite."""
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
player_id = _add_personnage(client, slug, screen_id, role="joueur")
|
|
enemy_id = _add_personnage(client, slug, screen_id, role="ennemie")
|
|
|
|
objects = game_engine.list_scene_objects(slug, screen_id)
|
|
for o in objects:
|
|
o["rendered_html"] = game_engine.render_scene_object(o)
|
|
rule_objects = game_engine.build_collision_rule_objects(objects)
|
|
ids = [o["id"] for o in rule_objects]
|
|
assert enemy_id in ids
|
|
assert player_id not in ids
|
|
|
|
|
|
def test_game_play_online_route_loads_the_scripts_that_apply_a_variable_action(
|
|
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
|
) -> None:
|
|
"""Bug corrigé : compute-operation.js/apply-actions.js (qui portent
|
|
forgeApplyVariableActionOffline, appelée par collision-rules-controller.js
|
|
pour l'action "Modifier une variable") n'étaient chargés QUE derrière
|
|
`{% if offline_mode %}` dans templates/game/play.html — en aperçu créateur
|
|
(/game/<slug>/play, jamais offline_mode), l'action ne faisait donc
|
|
RIEN (fonction absente). Les deux scripts doivent être chargés
|
|
inconditionnellement (voir aussi test_export_scorm.py, qui vérifie
|
|
qu'ils restent bien présents dans le paquet exporté)."""
|
|
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
html = client.get(f"/game/{slug}/play").data.decode()
|
|
assert 'src="/static/game/js/play/offline/compute-operation.js"' in html
|
|
assert 'src="/static/game/js/play/offline/apply-actions.js"' in html
|
|
|
|
|
|
def test_add_scene_object_with_image_url_sets_src_without_background_library(
|
|
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
|
) -> None:
|
|
""" "Mes assets"/IA (voir plan Phase 1, §1) : une image ARBITRAIRE,
|
|
jamais une clé de BACKGROUND_LIBRARY."""
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
object_id = game_engine.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png")
|
|
obj = not_none(game_engine.get_scene_object(slug, object_id))
|
|
assert obj["attributes"]["src"] == "/assets/1/abc.png"
|
|
|
|
|
|
def test_add_scene_object_image_url_takes_priority_over_background_slug(
|
|
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
|
) -> None:
|
|
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
|
object_id = game_engine.add_scene_object(
|
|
slug,
|
|
screen_id,
|
|
kind="fond",
|
|
background_slug="ne-existe-pas",
|
|
image_url="/assets/1/abc.png",
|
|
)
|
|
obj = not_none(game_engine.get_scene_object(slug, object_id))
|
|
assert obj["attributes"]["src"] == "/assets/1/abc.png"
|