Files
Forge-Engine/tests/test_collision_rules.py
T
williamandClaude Sonnet 5 c57420c8c9 Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y
Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

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

1178 lines
42 KiB
Python

"""Règles de collision (voir screens/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/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 screens
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 = screens.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(screens.get_scene_object(slug, enemy_id))
assert screens.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 = screens.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
screens/rendering/collision_rules.py::_MAX_ACTION_DEPTH)."""
rules = screens.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 = screens.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 = screens.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 = screens.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 = screens.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 = screens.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 = screens.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 = screens.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 = 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,
},
}
]
)
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 = screens.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 = screens.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 = screens.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
assert screens.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 screens/labels/data_operations.py) — utilisable
partout où une feuille est attendue : ici directement après une
collision."""
rules = screens.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 = screens.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 = screens.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 = screens.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 = screens.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
screens/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 = screens.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 = 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() -> None:
rules = screens.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
attached = screens.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 = screens.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {
"type": "variable",
"id": "v_1",
"target_variable": "score",
"data_operation": "incrementer",
"data_value": "1",
},
}
]
)
attached = screens.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 = screens.sanitize_collision_rules(
[
{
"trigger": "collision",
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
}
]
)
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_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 = screens.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 = screens.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 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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
rules = screens.sanitize_collision_rules([{"trigger": "clic", "action": {"type": "son", "asset_url": ""}}])
assert rules == []
def test_sanitize_collision_rules_keeps_a_video_action() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
"""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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> None:
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() -> 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 = 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() -> 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 = 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() -> None:
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() -> None:
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() -> None:
rules = screens.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 = screens.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:
"""screens.build_collision_rule_objects (voir screens/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 = screens.list_scene_objects(slug, screen_id)
for o in objects:
o["rendered_html"] = screens.render_scene_object(o)
rule_objects = screens.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/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/js/play/offline/compute-operation.js"' in html
assert 'src="/static/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 = screens.add_scene_object(slug, screen_id, kind="decor", image_url="/assets/1/abc.png")
obj = not_none(screens.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 = screens.add_scene_object(
slug,
screen_id,
kind="fond",
background_slug="ne-existe-pas",
image_url="/assets/1/abc.png",
)
obj = not_none(screens.get_scene_object(slug, object_id))
assert obj["attributes"]["src"] == "/assets/1/abc.png"