Enrichit les declencheurs/actions de scene (clic/survol/affichage, surbrillance/video/son/visibilite/indication/attendre) et fiabilise la pose d'un fond/decor importe
- Ajoute clic/survol/affichage-ecran comme declencheurs, et surbrillance, video, son, visibilite, indication, attendre comme actions, utilisables aussi bien par l'editeur manuel (menu lateral Objets/Ecran) que par Ruby (IA), avec blocs deplacables/supprimables dans une chaine. - Corrige plusieurs variantes du bug "impossible de poser un objet hors du champ de la camera" (troncature du chainage d'actions a 4 maillons, fond importe pose a 128x128 au lieu de sa taille reelle, decalage du fond au vrai glisser-depose, redimensionnement manuel jamais propage au monde). - Ajoute un vrai glisser-depose depuis la galerie vers la scene, la gestion complete de "Mes assets" (sous-sections Fonds/Decors/Sons/ Videos, suppression, reclassement fond<->decor sans re-upload). - Ajoute l'upload de son (limite 3 min) et de video (MP4 uniquement, limite 5 min), avec validation de la duree reelle du fichier, et une replique audio optionnelle dans une bulle de dialogue. - Fixe la taille de pose d'un objet/decor importe a 200x200 avec une boite de collision de 150x150. - Filtre le selecteur de fichier des actions son/video par type reel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
b07b231a61
commit
559331f9cf
@@ -148,6 +148,40 @@ def test_describe_scene_state_lists_existing_objects_with_role_and_trigger_flag(
|
||||
assert "[déclencheur déjà configuré]" in description
|
||||
|
||||
|
||||
def test_describe_scene_state_reports_no_screen_trigger_by_default(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert "Aucun déclencheur d'écran" in description
|
||||
|
||||
|
||||
def test_describe_scene_state_reports_an_existing_screen_trigger(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id, _ = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue"}},
|
||||
]))
|
||||
description = ai.chat._describe_scene_state(slug, screen_id)
|
||||
assert "1 déclencheur(s) D'ÉCRAN" in description
|
||||
|
||||
|
||||
def test_system_prompt_documents_the_new_triggers_and_actions():
|
||||
"""Bug à éviter : ajouter un type de déclencheur/action côté moteur
|
||||
(screens/rendering/collision_rules.py) sans jamais le documenter dans
|
||||
le system prompt le rendrait invisible pour Ruby malgré l'outil qui
|
||||
l'accepte techniquement."""
|
||||
prompt = ai.chat._SYSTEM_PROMPT
|
||||
for keyword in ("\"clic\"", "\"survol\"", "\"affichage\"", "surbrillance", "visibilite", "\"son\"", "\"video\"", "indication"):
|
||||
assert keyword in prompt, keyword
|
||||
|
||||
|
||||
def test_system_prompt_asks_ruby_to_clarify_ambiguous_trigger_choice():
|
||||
assert "pose la question" in ai.chat._SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_asks_ruby_to_clarify_interagir_vs_immediate_action():
|
||||
assert "interagir" in ai.chat._SYSTEM_PROMPT
|
||||
assert "APPUYER SUR UNE TOUCHE" in ai.chat._SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_run_chat_turn_passes_the_scene_state_in_the_system_prompt(client, tmp_game_slug_cleanup, monkeypatch):
|
||||
slug, screen_id, conversation_id = _create_jeu2d_game_with_conversation(client, tmp_game_slug_cleanup)
|
||||
screens.add_scene_object(slug, screen_id, kind="personnage")
|
||||
|
||||
@@ -200,6 +200,92 @@ def test_dispatch_append_action_to_trigger_extends_an_existing_chain(client, tmp
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score_ia"
|
||||
|
||||
|
||||
def test_dispatch_set_collision_rules_accepts_clic_and_survol_triggers(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
rules = [{"trigger": "clic", "action": {"type": "surbrillance", "object_id": object_id, "valeur": "toggle"}}]
|
||||
dispatch_tool(slug, screen_id, 1, "set_collision_rules", {"object_id": object_id, "rules": rules})
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj) == rules
|
||||
|
||||
|
||||
def test_dispatch_set_screen_triggers_sanitizes_and_stores(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
rules = [{"trigger": "affichage", "action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"}}]
|
||||
result = dispatch_tool(slug, screen_id, 1, "set_screen_triggers", {"rules": rules})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == rules
|
||||
|
||||
|
||||
def test_dispatch_append_action_to_screen_trigger_extends_an_existing_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "append_action_to_screen_trigger", {
|
||||
"after_id": "d_1",
|
||||
"action": {"type": "variable", "target_variable": "score_ia", "data_operation": "incrementer", "data_value": "1"},
|
||||
})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen)[0]["action"]["then"]["target_variable"] == "score_ia"
|
||||
|
||||
|
||||
def test_dispatch_remove_trigger_action_removes_a_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "remove_trigger_action", {"object_id": object_id, "leaf_id": "d_1"})
|
||||
assert result["ok"] is True
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_dispatch_move_trigger_action_swaps_a_leaf_up(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
object_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.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": []}},
|
||||
},
|
||||
}]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "move_trigger_action", {"object_id": object_id, "leaf_id": "d_1", "direction": "up"})
|
||||
assert result["ok"] is True
|
||||
obj = screens.get_scene_object(slug, object_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_dispatch_remove_screen_trigger_action_removes_a_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "remove_screen_trigger_action", {"leaf_id": "d_1"})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_dispatch_move_screen_trigger_action_swaps_a_leaf_up(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, screens.sanitize_screen_triggers([{
|
||||
"trigger": "affichage",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}]))
|
||||
result = dispatch_tool(slug, screen_id, 1, "move_screen_trigger_action", {"leaf_id": "d_1", "direction": "up"})
|
||||
assert result["ok"] is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen)[0]["action"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_dispatch_tool_raises_for_an_unknown_tool_name(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
try:
|
||||
@@ -269,6 +355,24 @@ def test_set_collision_rules_action_type_enum_matches_the_source_constant():
|
||||
assert action_schema["properties"]["type"]["enum"] == list(ACTION_TYPES)
|
||||
|
||||
|
||||
def test_set_collision_rules_trigger_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import TRIGGER_TYPES
|
||||
rules_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]
|
||||
assert rules_schema["properties"]["trigger"]["enum"] == list(TRIGGER_TYPES)
|
||||
|
||||
|
||||
def test_set_screen_triggers_trigger_enum_matches_the_source_constant():
|
||||
from screens.rendering.screen_triggers import TRIGGER_TYPES_SCREEN
|
||||
rules_schema = _tool("set_screen_triggers")["input_schema"]["properties"]["rules"]["items"]
|
||||
assert rules_schema["properties"]["trigger"]["enum"] == list(TRIGGER_TYPES_SCREEN)
|
||||
|
||||
|
||||
def test_set_collision_rules_video_mode_enum_matches_the_source_constant():
|
||||
from screens.labels.video_modes import VIDEO_MODE_LABELS
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
assert set(action_schema["properties"]["mode"]["enum"]) == set(VIDEO_MODE_LABELS)
|
||||
|
||||
|
||||
def test_set_collision_rules_condition_operator_enum_matches_the_source_constant():
|
||||
from screens.rendering.collision_rules import CONDITION_OPERATOR_KEYS
|
||||
action_schema = _tool("set_collision_rules")["input_schema"]["properties"]["rules"]["items"]["properties"]["action"]
|
||||
|
||||
@@ -257,10 +257,14 @@ def test_sanitize_collision_rules_keeps_a_then_chain_of_leaves():
|
||||
def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
|
||||
"""_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)."""
|
||||
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
|
||||
screens/rendering/collision_rules.py::_MAX_THEN_CHAIN."""
|
||||
chain = {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
node = chain
|
||||
for _ in range(10): # largement au-delà de _MAX_THEN_CHAIN (4)
|
||||
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 = screens.sanitize_collision_rules([{"trigger": "collision", "action": chain}])
|
||||
@@ -269,7 +273,36 @@ def test_sanitize_collision_rules_bounds_a_then_chain_that_is_too_long():
|
||||
while node:
|
||||
depth += 1
|
||||
node = node.get("then")
|
||||
assert depth == 4 # _MAX_THEN_CHAIN, jamais plus
|
||||
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():
|
||||
"""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 = screens.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():
|
||||
@@ -300,7 +333,11 @@ def test_set_then_action_in_rules_returns_false_for_an_unknown_leaf_id():
|
||||
assert screens.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_never_overwrites_an_existing_then():
|
||||
def test_set_then_action_in_rules_inserts_between_a_leaf_and_its_existing_then():
|
||||
"""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 = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
@@ -309,8 +346,321 @@ def test_set_then_action_in_rules_never_overwrites_an_existing_then():
|
||||
},
|
||||
}])
|
||||
attached = screens.set_then_action_in_rules(rules, "d_1", {"type": "variable", "target_variable": "vies", "data_operation": "decrementer", "data_value": "1"})
|
||||
assert attached is False
|
||||
assert rules[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
||||
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():
|
||||
""""clic"/"survol" — nouveaux déclencheurs sans besoin d'un joueur,
|
||||
voir screens/rendering/collision_rules.py."""
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.sanitize_collision_rules([
|
||||
{"trigger": "collision", "action": {"type": "visibilite", "object_id": 3, "valeur": "sorcellerie"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_son_action():
|
||||
rules = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_collision_rules_keeps_a_video_action():
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.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 = screens.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():
|
||||
rules = screens.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():
|
||||
"""Une "video" reste une FEUILLE chaînable, comme dialogue/variable
|
||||
(demande explicite : pouvoir enchaîner plusieurs actions)."""
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "clic",
|
||||
"action": {"type": "surbrillance", "id": "s_1", "object_id": 7, "valeur": "on"},
|
||||
}])
|
||||
attached = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
removed = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
removed = screens.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():
|
||||
rules = screens.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 = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
assert screens.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():
|
||||
rules = screens.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 = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
}])
|
||||
removed = screens.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():
|
||||
rules = screens.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 = screens.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():
|
||||
rules = screens.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 = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
assert screens.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():
|
||||
"""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 = [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}]
|
||||
changed = screens.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():
|
||||
rules = [{
|
||||
"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 = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
changed = screens.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():
|
||||
rules = screens.sanitize_collision_rules([{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
changed = screens.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():
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Lignes de dialogue (voir db/dialogue_lines.py) — réplique audio
|
||||
(demande explicite : "ajouter une réplique audio"), voix off jouée en
|
||||
même temps que la bulle de réplique s'affiche (voir static/js/play/
|
||||
dialogue-box-controller.js::forgeRenderDialogueBoxLine)."""
|
||||
import db
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_keeps_a_valid_audio_url_on_a_dialogue_line():
|
||||
lines = db.sanitize_dialogue_lines([
|
||||
{"speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"},
|
||||
])
|
||||
assert lines == [{"type": "dialogue", "speaker": "Marc", "text": "Bonjour !", "audio_url": "/assets/1/bonjour.mp3"}]
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_omits_audio_url_when_absent():
|
||||
lines = db.sanitize_dialogue_lines([{"speaker": "Marc", "text": "Bonjour !"}])
|
||||
assert "audio_url" not in lines[0]
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_omits_a_blank_audio_url():
|
||||
lines = db.sanitize_dialogue_lines([{"speaker": "Marc", "text": "Bonjour !", "audio_url": " "}])
|
||||
assert "audio_url" not in lines[0]
|
||||
|
||||
|
||||
def test_sanitize_dialogue_lines_ignores_audio_url_on_a_question_line():
|
||||
"""L'audio n'a de sens que pour une RÉPLIQUE — une question à choix
|
||||
("❓ Question") reste inchangée, voir QUESTION_REWARD_TYPES."""
|
||||
lines = db.sanitize_dialogue_lines([{
|
||||
"type": "question", "text": "2+2 ?", "choices": ["3", "4"], "correct_index": 1,
|
||||
"audio_url": "/assets/1/question.mp3",
|
||||
}])
|
||||
assert "audio_url" not in lines[0]
|
||||
@@ -23,6 +23,62 @@ def test_jeu2d_game_dispatches_to_scene_editor(client, tmp_game_slug_cleanup):
|
||||
assert "Personnages" in html
|
||||
|
||||
|
||||
def test_scene_edit_view_splits_my_assets_into_fond_decor_sons_videos_subsections(client, tmp_game_slug_cleanup):
|
||||
"""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 = 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, tmp_game_slug_cleanup):
|
||||
"""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, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.post(
|
||||
|
||||
@@ -4,9 +4,18 @@ JSON (posée quand l'appelant envoie X-Requested-With: fetch) remplace le
|
||||
rechargement complet de page — sans ça, poser un objet "vidait" un instant
|
||||
l'éditeur de collision/quêtes (tout l'état JS repartait de zéro à chaque
|
||||
ajout)."""
|
||||
import io
|
||||
import struct
|
||||
|
||||
import auth
|
||||
import screens
|
||||
|
||||
|
||||
def _png_bytes(width, height):
|
||||
ihdr = struct.pack(">II", width, height) + b"\x08\x02\x00\x00\x00"
|
||||
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IHDR" + ihdr + b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_add_ajax"):
|
||||
resp = client.post("/games/new", data={"name": name}, follow_redirects=False)
|
||||
slug = tmp_game_slug_cleanup(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
@@ -31,6 +40,83 @@ def test_ajax_add_returns_json_instead_of_redirecting(client, tmp_game_slug_clea
|
||||
assert body["animations"]
|
||||
|
||||
|
||||
def test_ajax_add_with_drop_position_places_the_object_exactly_there(client, tmp_game_slug_cleanup):
|
||||
"""Vrai glisser-déposer (demande explicite : "n'importe où sur la
|
||||
scène") — x/y postés remplacent le défaut 100/100."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="decor", x="300", y="220")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["x"] == 300
|
||||
assert body["y"] == 220
|
||||
|
||||
|
||||
def test_ajax_add_with_drop_position_near_the_edge_is_clamped_into_the_camera(client, tmp_game_slug_cleanup):
|
||||
"""Un dépôt trop proche d'un bord ne doit jamais laisser l'objet
|
||||
déborder du cadre visible par la caméra une fois sa taille réelle
|
||||
connue (même esprit que ai/tools.py::_clamp_to_camera pour l'IA)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="decor", x="999999", y="999999")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["x"] <= 960 - body["width"]
|
||||
assert body["y"] <= 540 - body["height"]
|
||||
|
||||
|
||||
# ---------- Fond importé ("Mes assets") posé à sa taille RÉELLE — bug :
|
||||
# "je ne peux pas poser un objet hors du champ de la caméra quand
|
||||
# j'utilise un fond de scène importé" (sans ça, l'image retombait
|
||||
# toujours sur 128×128, trop petite pour agrandir le "monde" au-delà de
|
||||
# la caméra nominale 960×540) ----------
|
||||
|
||||
def test_ajax_add_fond_ignores_the_drop_position_and_stays_anchored_at_the_origin(client, tmp_game_slug_cleanup):
|
||||
"""Bug corrigé ("décalage") : le vrai glisser-déposer envoie une
|
||||
position EXACTE même pour un fond — sans ce garde-fou, un fond
|
||||
déposé ailleurs qu'au coin haut-gauche restait décalé, laissant un
|
||||
bandeau de canevas vide visible entre le bord de la scène et
|
||||
l'image."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="fond", x="450", y="300")
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert (body["x"], body["y"]) == (0, 0)
|
||||
|
||||
|
||||
def test_ajax_add_fond_from_user_asset_uses_the_real_image_dimensions(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id, _filename = auth.save_user_asset(
|
||||
user_id, _png_bytes(1920, 1080), ".png", original_name="ciel.png", source="upload", scene_kind="fond",
|
||||
)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="fond", asset_id=str(asset_id))
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body["width"] == 1920
|
||||
assert body["height"] == 1080
|
||||
|
||||
|
||||
# ---------- Redimensionnement manuel d'un "fond" — la route /geometry
|
||||
# doit renvoyer le "monde" recalculé pour que scene-editor.js puisse
|
||||
# rafraîchir SCENE_WIDTH/SCENE_HEIGHT tout de suite (même bug que
|
||||
# ci-dessus, mais via le redimensionnement à la souris plutôt qu'un
|
||||
# import — sans ça, l'agrandissement n'était visible qu'après un
|
||||
# rechargement complet de la page) ----------
|
||||
|
||||
def test_geometry_route_returns_the_recomputed_world_size_for_an_enlarged_fond(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = _add_ajax(client, slug, screen_id, kind="fond")
|
||||
obj_id = resp.get_json()["id"]
|
||||
geo = client.post(
|
||||
f"/game/{slug}/scene-objects/{obj_id}/geometry",
|
||||
json={"x": 0, "y": 0, "width": 2000, "height": 1200},
|
||||
)
|
||||
assert geo.status_code == 200
|
||||
body = geo.get_json()
|
||||
assert body["world_width"] >= 2000
|
||||
assert body["world_height"] >= 1200
|
||||
|
||||
|
||||
def test_non_ajax_add_still_redirects(client, tmp_game_slug_cleanup):
|
||||
"""Secours pour tout appelant non-JS (aucun connu, mais gratuit à garder) —
|
||||
comportement inchangé : redirection classique, sans le header AJAX."""
|
||||
|
||||
@@ -40,6 +40,33 @@ def test_add_decor_scene_object(tmp_game_slug_cleanup):
|
||||
assert obj["kind"] == "decor"
|
||||
|
||||
|
||||
def test_add_decor_scene_object_with_an_image_defaults_to_200x200_with_a_150x150_collision_box(tmp_game_slug_cleanup):
|
||||
"""Demande explicite : un objet/décor posé depuis "Mes assets" doit
|
||||
toujours démarrer à 200×200 (jamais la taille réelle, souvent bien
|
||||
plus grande, ni le défaut 128×128 du schéma), avec une boîte de
|
||||
collision plus petite (150×150, centrée) pour rester proche de la
|
||||
silhouette réelle."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/photo.png")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert (obj["width"], obj["height"]) == (200, 200)
|
||||
collision = screens.resolve_collision_settings(obj)
|
||||
assert (collision["width"], collision["height"]) == (150, 150)
|
||||
|
||||
|
||||
def test_add_decor_scene_object_with_an_image_ignores_real_image_dimensions(tmp_game_slug_cleanup):
|
||||
"""Contrairement à "fond" (voir add_scene_object.py), un "decor" ne
|
||||
doit JAMAIS être posé à la taille réelle de l'image importée — même
|
||||
quand elle est connue (image_width/image_height fournis)."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(
|
||||
slug, screen_id, kind="decor", image_url="/assets/1/photo.png",
|
||||
image_width=5504, image_height=3072,
|
||||
)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert (obj["width"], obj["height"]) == (200, 200)
|
||||
|
||||
|
||||
def test_scene_object_geometry_is_pixels_not_percent(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
@@ -108,6 +135,44 @@ def test_render_scene_object_fond_alt_is_always_empty_rgaa(tmp_game_slug_cleanup
|
||||
assert 'alt=""' in html
|
||||
|
||||
|
||||
def test_add_scene_object_with_explicit_position_uses_it_instead_of_the_default(tmp_game_slug_cleanup):
|
||||
"""Vrai glisser-déposer depuis la galerie (demande explicite :
|
||||
"n'importe où sur la scène") — x/y explicites remplacent le défaut
|
||||
100/100 posé par le schéma (voir ensure_scene_schema.py)."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="decor", x=250, y=340)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 250
|
||||
assert obj["y"] == 340
|
||||
|
||||
|
||||
def test_add_scene_object_without_position_still_defaults_to_100_100(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="decor")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 100
|
||||
assert obj["y"] == 100
|
||||
|
||||
|
||||
def test_add_scene_object_fond_with_explicit_position_overrides_the_top_left_default(tmp_game_slug_cleanup):
|
||||
"""Un "fond" reste au coin haut-gauche (0,0) SANS position explicite
|
||||
(comportement inchangé), mais un glisser-déposer précis doit pouvoir
|
||||
le positionner ailleurs si le créateur le demande explicitement."""
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret", x=50, y=60)
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 50
|
||||
assert obj["y"] == 60
|
||||
|
||||
|
||||
def test_add_scene_object_fond_without_position_still_defaults_to_top_left(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id, kind="fond", background_slug="foret")
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert obj["x"] == 0
|
||||
assert obj["y"] == 0
|
||||
|
||||
|
||||
def test_delete_scene_object_cleans_up_referencing_flow_nodes(tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(tmp_game_slug_cleanup)
|
||||
obj_id = screens.add_scene_object(slug, screen_id)
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Déclencheurs D'ÉCRAN (voir screens/rendering/screen_triggers.py) : une
|
||||
règle "déclencheur (affichage) -> action" posée sur un ÉCRAN plutôt que
|
||||
sur un objet — SANS objet requis, pour de la narration/cinématique dès
|
||||
l'affichage. Section "Déclencheurs de l'écran" de l'éditeur de scène."""
|
||||
import screens
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_screen_triggers"):
|
||||
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_freshly_created_screen_has_no_screen_triggers(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_keeps_an_affichage_trigger():
|
||||
rules = screens.sanitize_screen_triggers([
|
||||
{"trigger": "affichage", "action": {"type": "dialogue"}},
|
||||
])
|
||||
assert rules == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_rejects_triggers_not_allowed_at_screen_level():
|
||||
"""Un déclencheur d'OBJET ("collision"/"clic"/"survol") n'a pas de
|
||||
sens sans objet — seul "affichage" est autorisé pour un déclencheur
|
||||
d'écran (voir TRIGGER_TYPES_SCREEN)."""
|
||||
rules = screens.sanitize_screen_triggers([
|
||||
{"trigger": "collision", "action": {"type": "dialogue"}},
|
||||
{"trigger": "clic", "action": {"type": "dialogue"}},
|
||||
{"trigger": "survol", "action": {"type": "dialogue"}},
|
||||
])
|
||||
assert rules == []
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_keeps_a_video_action():
|
||||
rules = screens.sanitize_screen_triggers([{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
||||
}])
|
||||
assert rules == [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "video", "asset_url": "/assets/1/intro.mp4", "mode": "plein_ecran"},
|
||||
}]
|
||||
|
||||
|
||||
def test_sanitize_screen_triggers_keeps_an_attendre_then_dialogue_chain():
|
||||
"""Cas d'usage demandé : "à l'affichage de l'écran" -> attendre N
|
||||
secondes -> déclencher un dialogue."""
|
||||
rules = screens.sanitize_screen_triggers([{
|
||||
"trigger": "affichage",
|
||||
"action": {
|
||||
"type": "attendre", "data_value": "3",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
assert rules[0]["action"]["type"] == "attendre"
|
||||
assert rules[0]["action"]["then"]["type"] == "dialogue"
|
||||
|
||||
|
||||
def test_set_screen_triggers_persists_and_resolve_reads_it_back(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_triggers_are_isolated_between_screens(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id_1 = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screen_id_2 = screens.create_screen(slug, "Scène 2", kind="jeu_2d")
|
||||
screens.set_screen_triggers(slug, screen_id_1, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
screen_2 = screens.get_screen(slug, screen_id_2)
|
||||
assert screens.resolve_screen_triggers(screen_2) == []
|
||||
|
||||
|
||||
def test_append_action_to_screen_trigger_attaches_a_leaf_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
ok = screens.append_action_to_screen_trigger(
|
||||
slug, screen_id, "d_1",
|
||||
{"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
)
|
||||
assert ok is True
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert rules[0]["action"]["then"]["type"] == "variable"
|
||||
|
||||
|
||||
def test_append_action_to_screen_trigger_returns_false_for_an_unknown_screen(client, tmp_game_slug_cleanup):
|
||||
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
assert screens.append_action_to_screen_trigger(slug, 999999, "d_1", {"type": "dialogue"}) is False
|
||||
|
||||
|
||||
def test_full_game_payload_exposes_screen_triggers(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
payload = screens.full_game_payload(slug)
|
||||
scene_screen = next(s for s in payload["screens"] if s["id"] == screen_id)
|
||||
assert scene_screen["screen_triggers"] == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_triggers_get_route_returns_the_saved_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
resp = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_triggers_post_route_appends_a_rule(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}/triggers",
|
||||
json={"rule": {"trigger": "affichage", "action": {"type": "dialogue"}}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == [{"trigger": "affichage", "action": {"type": "dialogue"}}]
|
||||
|
||||
|
||||
def test_screen_trigger_delete_one_route_removes_only_the_targeted_rule(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [
|
||||
{"trigger": "affichage", "action": {"type": "dialogue"}},
|
||||
{"trigger": "affichage", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
|
||||
])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/0")
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert len(rules) == 1
|
||||
assert rules[0]["action"]["type"] == "son"
|
||||
|
||||
|
||||
def test_screen_trigger_delete_one_route_404s_for_an_out_of_range_index(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/0")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_screen_triggers_delete_route_clears_all_rules(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{"trigger": "affichage", "action": {"type": "dialogue"}}])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_screen_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
|
||||
resp1 = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
leaf_id = resp1.get_json()[0]["action"]["id"]
|
||||
assert leaf_id
|
||||
|
||||
resp2 = client.get(f"/game/{slug}/screens/{screen_id}/triggers")
|
||||
assert resp2.get_json()[0]["action"]["id"] == leaf_id # stable
|
||||
|
||||
append_resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers/append-action",
|
||||
json={"after_id": leaf_id, "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
)
|
||||
assert append_resp.status_code == 200
|
||||
|
||||
|
||||
def test_screen_triggers_get_route_404s_for_an_unknown_screen(client, tmp_game_slug_cleanup):
|
||||
slug, _ = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
resp = client.get(f"/game/{slug}/screens/999999/triggers")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_screen_trigger_dialogue_update_route_edits_the_matching_dialogue(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers/dialogue",
|
||||
json={"dialogue_id": "d_1", "lines": [{"speaker": "narrateur", "text": "Bienvenue !"}]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "narrateur", "text": "Bienvenue !"}]
|
||||
|
||||
|
||||
def test_screen_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1")
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
assert screens.resolve_screen_triggers(screen) == []
|
||||
|
||||
|
||||
def test_screen_trigger_remove_action_route_404s_for_an_unknown_leaf_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.delete(f"/game/{slug}/screens/{screen_id}/triggers/action/d_absent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_screen_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {
|
||||
"type": "attendre", "id": "a_1", "data_value": "2",
|
||||
"then": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
},
|
||||
}])
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1/move", json={"direction": "up"})
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
action = screens.resolve_screen_triggers(screen)[0]["action"]
|
||||
assert action["type"] == "dialogue"
|
||||
assert action["then"]["type"] == "attendre"
|
||||
|
||||
|
||||
def test_screen_trigger_move_action_route_400s_for_an_invalid_direction(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/triggers/action/d_1/move", json={"direction": "sideways"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_screen_trigger_append_action_route_attaches_a_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
screens.set_screen_triggers(slug, screen_id, [{
|
||||
"trigger": "affichage",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{screen_id}/triggers/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "son", "asset_url": "/assets/1/bip.mp3"}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
screen = screens.get_screen(slug, screen_id)
|
||||
rules = screens.resolve_screen_triggers(screen)
|
||||
assert rules[0]["action"]["then"] == {"type": "son", "asset_url": "/assets/1/bip.mp3"}
|
||||
+96
-3
@@ -295,7 +295,11 @@ def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_ga
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then(client, tmp_game_slug_cleanup):
|
||||
"""Demande explicite : "pouvoir ajouter n'importe quelle action à
|
||||
n'importe quel niveau du schéma" — poser "+ Ajouter une action" sur
|
||||
un bloc qui a déjà une suite INSÈRE la nouvelle action devant cette
|
||||
suite plutôt que d'échouer (comportement précédent)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
@@ -310,9 +314,11 @@ def test_trigger_append_action_route_404s_when_the_leaf_already_has_a_then(clien
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj)[0]["action"]["then"]["target_variable"] == "score" # inchangé
|
||||
action = screens.resolve_collision_rules(obj)[0]["action"]
|
||||
assert action["then"]["target_variable"] == "vies"
|
||||
assert action["then"]["then"]["target_variable"] == "score"
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
|
||||
@@ -320,6 +326,89 @@ def test_trigger_append_action_route_404s_for_an_unknown_object(client, game):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_1")
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_trigger_remove_action_route_404s_for_an_unknown_leaf_id(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}/action/d_absent")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"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": []}},
|
||||
},
|
||||
}])
|
||||
|
||||
resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "up"})
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
action = screens.resolve_collision_rules(obj)[0]["action"]
|
||||
assert action["type"] == "dialogue"
|
||||
assert action["then"]["target_variable"] == "score"
|
||||
|
||||
|
||||
def test_trigger_move_action_route_400s_for_an_invalid_direction(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}])
|
||||
|
||||
resp = client.post(f"/game/{slug}/triggers/{obj_id}/action/d_1/move", json={"direction": "sideways"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp_game_slug_cleanup):
|
||||
"""Bug corrigé : une action "variable" posée sans "id" (par une
|
||||
session antérieure ou par l'IA) restait à jamais impossible à
|
||||
adresser via append-action — le premier GET /triggers doit la rendre
|
||||
adressable, DE FAÇON STABLE (persisté, pas régénéré à chaque appel)."""
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
_set_rules(slug, obj_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
}])
|
||||
|
||||
resp1 = client.get(f"/game/{slug}/triggers")
|
||||
leaf_id = resp1.get_json()[0]["chain"][0]["id"]
|
||||
assert leaf_id
|
||||
|
||||
resp2 = client.get(f"/game/{slug}/triggers")
|
||||
assert resp2.get_json()[0]["chain"][0]["id"] == leaf_id # stable, pas régénéré
|
||||
|
||||
append_resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": leaf_id, "action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}},
|
||||
)
|
||||
assert append_resp.status_code == 200
|
||||
|
||||
|
||||
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_personnage(client, slug, screen_id, role="pnj")
|
||||
@@ -350,6 +439,10 @@ def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/append-action", json={})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.delete("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1")
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/triggers/1/action/d_1/move", json={"direction": "up"})
|
||||
assert resp.status_code == 403
|
||||
resp = anon_client.get("/game/un-projet-qui-nest-pas-le-sien/triggers/objects-catalog")
|
||||
assert resp.status_code == 403
|
||||
finally:
|
||||
|
||||
@@ -3,10 +3,54 @@ COMPTE, distincte du dossier d'un jeu (routes/uploads/upload_file.py) :
|
||||
réutilisable d'un projet à l'autre du MÊME compte, jamais visible par un
|
||||
autre."""
|
||||
import io
|
||||
import struct
|
||||
import wave
|
||||
|
||||
import auth
|
||||
|
||||
|
||||
def _png_bytes(width, height):
|
||||
"""Un PNG minimal — seule la signature + l'en-tête IHDR (largeur/
|
||||
hauteur) comptent pour auth.image_dimensions, le reste du fichier
|
||||
n'a pas besoin d'être une image RÉELLEMENT décodable (voir
|
||||
add_scene_object.py, qui ne fait que lire ces dimensions, jamais le
|
||||
pixel lui-même)."""
|
||||
ihdr = struct.pack(">II", width, height) + b"\x08\x02\x00\x00\x00"
|
||||
return b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\x0d" + b"IHDR" + ihdr + b"\x00\x00\x00\x00"
|
||||
|
||||
|
||||
def _mp4_box(box_type, payload):
|
||||
return struct.pack(">I4s", len(payload) + 8, box_type) + payload
|
||||
|
||||
|
||||
def _mp4_bytes(seconds, timescale=1000):
|
||||
"""Un MP4 minimal — juste "ftyp" + "moov/mvhd" (durée/timescale),
|
||||
suffisant pour que mutagen (auth/validate_video_duration.py) lise sa
|
||||
durée, sans piste vidéo/audio réelle ni "mdat" (jamais décodé, seule
|
||||
la durée annoncée compte ici)."""
|
||||
ftyp = _mp4_box(b"ftyp", b"isom" + struct.pack(">I", 0) + b"isom" + b"mp42")
|
||||
duration = int(seconds * timescale)
|
||||
mvhd_payload = (
|
||||
struct.pack(">I", 0) + struct.pack(">II", 0, 0)
|
||||
+ struct.pack(">II", timescale, duration) + b"\x00" * 80
|
||||
)
|
||||
moov = _mp4_box(b"moov", _mp4_box(b"mvhd", mvhd_payload))
|
||||
return ftyp + moov
|
||||
|
||||
|
||||
def _wav_bytes(seconds, framerate=8000):
|
||||
"""Un WAV de silence, durée EXACTE (au sample près) — 8kHz mono 8-bit
|
||||
pour rester petit même sur 3+ minutes (voir test de la limite de
|
||||
durée d'un son, auth/validate_audio_duration.py)."""
|
||||
buf = io.BytesIO()
|
||||
with wave.open(buf, "wb") as w:
|
||||
w.setnchannels(1)
|
||||
w.setsampwidth(1)
|
||||
w.setframerate(framerate)
|
||||
w.writeframes(b"\x80" * int(seconds * framerate))
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_create_and_list_user_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
@@ -60,6 +104,70 @@ def test_upload_user_asset_route_requires_a_file(client):
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------- scene_kind ("fond"/"decor" — demande explicite : "un bouton
|
||||
# d'import séparé pour les images de fond et les objets") ----------
|
||||
|
||||
def test_create_user_asset_stores_the_scene_kind(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="fond")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == asset_id)
|
||||
assert asset["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_create_user_asset_scene_kind_defaults_to_none(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ancienne.png", source="upload")
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == asset_id)
|
||||
assert asset["scene_kind"] is None
|
||||
|
||||
|
||||
def test_upload_route_stores_the_posted_scene_kind_for_an_image(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "ciel.png"), "scene_kind": "fond"},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
|
||||
assert asset["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_upload_route_defaults_an_image_without_scene_kind_to_decor(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"fake-image-bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
|
||||
assert asset["scene_kind"] == "decor"
|
||||
|
||||
|
||||
def test_upload_route_ignores_scene_kind_for_a_non_image_file(client):
|
||||
"""scene_kind n'a de sens que pour une IMAGE (voir
|
||||
auth/user_asset_kind.py) — un son/une vidéo ne "posent" rien sur la
|
||||
scène, peu importe ce que le client aurait posté."""
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_wav_bytes(2)), "bip.wav"), "scene_kind": "fond"},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
asset = next(a for a in assets if a["id"] == resp.get_json()["id"])
|
||||
assert asset["scene_kind"] is None
|
||||
|
||||
|
||||
def test_serve_user_asset_route_403s_for_another_account(client, user_client):
|
||||
# Un compte "user" fraîchement inscrit (sans projet) est redirigé vers
|
||||
# l'onboarding pour toute route hors _REACHABLE_WITHOUT_PROJECT (voir
|
||||
@@ -78,3 +186,236 @@ def test_serve_user_asset_route_403s_for_another_account(client, user_client):
|
||||
url = resp.get_json()["url"]
|
||||
resp2 = user_client.get(url)
|
||||
assert resp2.status_code == 403
|
||||
|
||||
|
||||
# ---------- "Mes assets" — upload d'un son, limité à 3 minutes (demande
|
||||
# explicite) ----------
|
||||
|
||||
def test_user_asset_kind_classifies_by_extension():
|
||||
assert auth.user_asset_kind("photo.PNG") == "image"
|
||||
assert auth.user_asset_kind("bip.mp3") == "audio"
|
||||
assert auth.user_asset_kind("bip.WAV") == "audio"
|
||||
assert auth.user_asset_kind("notes.txt") == "other"
|
||||
|
||||
|
||||
def test_validate_audio_duration_accepts_a_short_wav():
|
||||
content = _wav_bytes(2)
|
||||
assert auth.validate_audio_duration(content) is None
|
||||
|
||||
|
||||
def test_validate_audio_duration_rejects_a_wav_over_the_limit():
|
||||
content = _wav_bytes(200) # > 180s (auth.MAX_AUDIO_SECONDS)
|
||||
error = auth.validate_audio_duration(content)
|
||||
assert error is not None
|
||||
assert "3 minutes" in error
|
||||
|
||||
|
||||
def test_validate_audio_duration_rejects_unrecognized_content():
|
||||
assert auth.validate_audio_duration(b"pas-un-vrai-fichier-audio") is not None
|
||||
|
||||
|
||||
def test_upload_route_accepts_a_short_audio_file(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_wav_bytes(2)), "bip.wav")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["filename"].endswith(".wav") for a in assets)
|
||||
|
||||
|
||||
def test_upload_route_rejects_an_audio_file_over_three_minutes(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_wav_bytes(200)), "trop-long.wav")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "3 minutes" in resp.get_json()["error"]
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert not any(a["original_name"] == "trop-long.wav" for a in assets)
|
||||
|
||||
|
||||
# ---------- "Mes assets" — upload d'une vidéo, format MP4 uniquement,
|
||||
# limité à 5 minutes (demande explicite) ----------
|
||||
|
||||
def test_validate_video_duration_accepts_a_short_mp4():
|
||||
assert auth.validate_video_duration(_mp4_bytes(30)) is None
|
||||
|
||||
|
||||
def test_validate_video_duration_rejects_an_mp4_over_the_limit():
|
||||
error = auth.validate_video_duration(_mp4_bytes(400))
|
||||
assert error is not None
|
||||
assert "5 minutes" in error
|
||||
|
||||
|
||||
def test_validate_video_duration_rejects_unrecognized_content():
|
||||
assert auth.validate_video_duration(b"pas-une-vraie-video") is not None
|
||||
|
||||
|
||||
def test_upload_route_accepts_a_short_mp4_video(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_mp4_bytes(30)), "clip.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert any(a["filename"].endswith(".mp4") for a in assets)
|
||||
|
||||
|
||||
def test_upload_route_rejects_an_mp4_video_over_five_minutes(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(_mp4_bytes(400)), "trop-long.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "5 minutes" in resp.get_json()["error"]
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert not any(a["original_name"] == "trop-long.mp4" for a in assets)
|
||||
|
||||
|
||||
def test_upload_route_rejects_a_non_mp4_video_format(client):
|
||||
"""Demande explicite : "une vidéo au format MP4" — webm/mov/avi sont
|
||||
reconnus comme "vidéo" par auth/user_asset_kind.py (rétrocompatibilité
|
||||
de l'organisation "Mes assets"), mais plus ACCEPTÉS à l'upload."""
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"peu-importe-le-contenu"), "clip.webm")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "MP4" in resp.get_json()["error"]
|
||||
|
||||
|
||||
def test_upload_route_rejects_unrecognized_mp4_content(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"pas-une-vraie-video"), "faux.mp4")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ---------- Suppression d'un asset (demande explicite : "voir, utiliser
|
||||
# ou supprimer des son, image et vidéo") ----------
|
||||
|
||||
def test_delete_user_asset_route_removes_it(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "abc123.png", original_name="photo.png", source="upload")
|
||||
resp = client.delete(f"/assets/{asset_id}")
|
||||
assert resp.status_code == 200
|
||||
assert auth.get_user_asset(asset_id) is None
|
||||
|
||||
|
||||
def test_delete_user_asset_route_404s_for_an_unknown_id(client):
|
||||
resp = client.delete("/assets/999999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_delete_user_asset_route_refuses_another_accounts_asset(client, user_client):
|
||||
# Un compte "user" fraîchement inscrit (sans projet) est redirigé vers
|
||||
# l'onboarding pour toute route hors _REACHABLE_WITHOUT_PROJECT (voir
|
||||
# core/auth_guard.py) — lui assigner un projet isole ce test sur le
|
||||
# SEUL comportement voulu : l'isolation par propriétaire.
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
auth.set_project_slug(other_user_id, "pytest_dummy_project_delete")
|
||||
asset_id = auth.create_user_asset(admin_id, "admin_only.png")
|
||||
resp = user_client.delete(f"/assets/{asset_id}")
|
||||
assert resp.status_code == 404
|
||||
assert auth.get_user_asset(asset_id) is not None
|
||||
|
||||
|
||||
# ---------- Reclasser un asset DÉJÀ importé entre "Fonds" et
|
||||
# "Décors/objets" sans ré-upload (diagnostiqué depuis un "décalage" : une
|
||||
# image importée par le mauvais bouton, ou avant la séparation fond/décor,
|
||||
# restait bloquée dans la mauvaise section) ----------
|
||||
|
||||
def test_update_user_asset_scene_kind_reclassifies_the_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
assert auth.update_user_asset_scene_kind(asset_id, user_id, "fond") is True
|
||||
asset = auth.get_user_asset(asset_id)
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert next(a for a in assets if a["id"] == asset_id)["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_update_user_asset_scene_kind_refuses_when_not_the_owner(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(admin_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
assert auth.update_user_asset_scene_kind(asset_id, other_user_id, "fond") is False
|
||||
|
||||
|
||||
def test_update_user_asset_scene_kind_refuses_a_non_image_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "bip.mp3", source="upload")
|
||||
assert auth.update_user_asset_scene_kind(asset_id, user_id, "fond") is False
|
||||
|
||||
|
||||
def test_set_scene_kind_route_reclassifies_the_asset(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
resp = client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "fond"})
|
||||
assert resp.status_code == 200
|
||||
assets = auth.list_user_assets(user_id)
|
||||
assert next(a for a in assets if a["id"] == asset_id)["scene_kind"] == "fond"
|
||||
|
||||
|
||||
def test_set_scene_kind_route_rejects_an_invalid_value(client):
|
||||
with client.session_transaction() as sess:
|
||||
user_id = sess["user_id"]
|
||||
asset_id = auth.create_user_asset(user_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
resp = client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "autre"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_set_scene_kind_route_404s_for_another_accounts_asset(client, user_client):
|
||||
with client.session_transaction() as sess:
|
||||
admin_id = sess["user_id"]
|
||||
with user_client.session_transaction() as sess:
|
||||
other_user_id = sess["user_id"]
|
||||
auth.set_project_slug(other_user_id, "pytest_dummy_project_scene_kind")
|
||||
asset_id = auth.create_user_asset(admin_id, "ciel.png", source="upload", scene_kind="decor")
|
||||
resp = user_client.post(f"/assets/{asset_id}/scene-kind", json={"scene_kind": "fond"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------- Dimensions réelles d'une image importée (bug : "je ne peux
|
||||
# pas poser un objet hors du champ de la caméra quand j'utilise un fond
|
||||
# de scène importé" — voir auth/image_dimensions.py) ----------
|
||||
|
||||
def test_image_dimensions_reads_a_png_header():
|
||||
assert auth.image_dimensions(_png_bytes(1920, 1080)) == (1920, 1080)
|
||||
|
||||
|
||||
def test_image_dimensions_returns_none_for_unrecognized_content():
|
||||
assert auth.image_dimensions(b"pas-une-image") is None
|
||||
|
||||
|
||||
def test_upload_route_rejects_unrecognized_audio_content(client):
|
||||
resp = client.post(
|
||||
"/assets/upload",
|
||||
data={"file": (io.BytesIO(b"pas-un-vrai-son"), "faux.mp3")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
Reference in New Issue
Block a user