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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
+373
-142
@@ -8,29 +8,41 @@ Chaque occurrence de "Déclencher ce dialogue" porte son propre dialogue
|
||||
ci-dessous). Source de données UNIQUE avec l'éditeur de collision : toute
|
||||
règle ajoutée/retirée/modifiée ici doit rester visible/cohérente via
|
||||
screens.resolve_collision_rules directement sur l'objet."""
|
||||
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
import screens
|
||||
from tests.conftest import not_none
|
||||
from tests.test_auth import anon_client # noqa: F401
|
||||
|
||||
|
||||
def _create_jeu2d_game(client, tmp_game_slug_cleanup, name="pytest_triggers"):
|
||||
def _create_jeu2d_game(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str], name: str = "pytest_triggers"
|
||||
) -> 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, slug, screen_id, role=None):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/scene-objects/add", data={"kind": "personnage"}, follow_redirects=False)
|
||||
def _add_personnage(client: FlaskClient, slug: str, screen_id: int, role: str | None = None) -> Any:
|
||||
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(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
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")
|
||||
client.post(
|
||||
f"/game/{slug}/scene-objects/{obj_id}/role", data=f'{{"role": "{role}"}}', content_type="application/json"
|
||||
)
|
||||
return obj_id
|
||||
|
||||
|
||||
def _set_rules(slug, object_id, rules):
|
||||
def _set_rules(slug: str, object_id: int, rules: Any) -> None:
|
||||
"""Pose des règles directement (sans passer par une route HTTP —
|
||||
l'ancienne route de sauvegarde, routes/scenes/scene_object_collision_rules.py,
|
||||
a été retirée avec l'éditeur de collision séparé) pour préparer un
|
||||
@@ -38,33 +50,39 @@ def _set_rules(slug, object_id, rules):
|
||||
screens.set_scene_object_collision_rules(slug, object_id, screens.sanitize_collision_rules(rules))
|
||||
|
||||
|
||||
def test_mark_completed_is_kept_when_true(client, tmp_game_slug_cleanup):
|
||||
def test_mark_completed_is_kept_when_true(client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]) -> None:
|
||||
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", "mark_completed": True}}])
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules[0]["action"]["mark_completed"] is True
|
||||
|
||||
|
||||
def test_mark_completed_is_dropped_when_false_or_absent(client, tmp_game_slug_cleanup):
|
||||
def test_mark_completed_is_dropped_when_false_or_absent(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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", "mark_completed": False}}])
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert "mark_completed" not in rules[0]["action"]
|
||||
|
||||
|
||||
def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_cleanup):
|
||||
def test_list_all_triggers_finds_direct_and_nested_rules(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
nested_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, direct_id, [{"trigger": "collision", "action": {"type": "dialogue"}}])
|
||||
_set_rules(slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}])
|
||||
_set_rules(
|
||||
slug, nested_id, [{"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue"}}}]
|
||||
)
|
||||
|
||||
result = screens.list_all_triggers(slug)
|
||||
ids = [o["object_id"] for o in result]
|
||||
@@ -76,7 +94,9 @@ def test_list_all_triggers_finds_direct_and_nested_rules(client, tmp_game_slug_c
|
||||
assert nested_entry["has_interagir"] is True
|
||||
|
||||
|
||||
def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(client, tmp_game_slug_cleanup):
|
||||
def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Losange à embranchement (maquette validée, lecture verticale) :
|
||||
Vrai ET Faux peuvent chacun déclencher LEUR PROPRE dialogue,
|
||||
indépendamment l'un de l'autre — plus de "branche qui mène à telle
|
||||
@@ -85,14 +105,23 @@ def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(c
|
||||
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": "condition", "variable": "inventaire_cle", "operateur": "superieur", "valeur": "0",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
|
||||
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}},
|
||||
},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition",
|
||||
"variable": "inventaire_cle",
|
||||
"operateur": "superieur",
|
||||
"valeur": "0",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_vrai", "lines": []}},
|
||||
"si_faux": {"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = screens.list_all_triggers(slug)
|
||||
entry = next(o for o in result if o["object_id"] == obj_id)
|
||||
@@ -101,35 +130,51 @@ def test_list_all_triggers_surfaces_both_branches_of_a_condition_independently(c
|
||||
assert entry["condition"]["si_faux"] == {"chain": [{"type": "dialogue", "dialogue": {"id": "d_faux", "lines": []}}]}
|
||||
|
||||
|
||||
def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(client, tmp_game_slug_cleanup):
|
||||
def test_list_all_triggers_leaves_a_branch_none_when_it_does_nothing(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": "condition", "variable": "score", "operateur": "egal", "valeur": "0",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}}, "si_faux": None,
|
||||
},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition",
|
||||
"variable": "score",
|
||||
"operateur": "egal",
|
||||
"valeur": "0",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
"si_faux": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
result = screens.list_all_triggers(slug)
|
||||
entry = next(o for o in result if o["object_id"] == obj_id)
|
||||
assert entry["condition"]["si_faux"] is None
|
||||
|
||||
|
||||
def test_triggers_delete_removes_all_rules_on_the_object(client, tmp_game_slug_cleanup):
|
||||
def test_triggers_delete_removes_all_rules_on_the_object(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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"}}])
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
|
||||
def test_triggers_route_add_then_get_then_delete(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
obj_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
@@ -137,18 +182,23 @@ def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
|
||||
f"/game/{slug}/triggers",
|
||||
json={
|
||||
"object_id": obj_id,
|
||||
"rule": {"trigger": "collision", "action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}}},
|
||||
"rule": {
|
||||
"trigger": "collision",
|
||||
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Même donnée, visible directement via la couche collision (source unique).
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules == [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
|
||||
}]
|
||||
assert rules == [
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "interagir", "sub_action": {"type": "dialogue", "mark_completed": True}},
|
||||
}
|
||||
]
|
||||
|
||||
resp = client.get(f"/game/{slug}/triggers")
|
||||
result = resp.get_json()
|
||||
@@ -157,95 +207,171 @@ def test_triggers_route_add_then_get_then_delete(client, tmp_game_slug_cleanup):
|
||||
|
||||
resp = client.delete(f"/game/{slug}/triggers/{obj_id}")
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
assert screens.resolve_collision_rules(obj) == []
|
||||
|
||||
|
||||
def test_list_all_triggers_surfaces_the_dialogue(client, tmp_game_slug_cleanup):
|
||||
def test_list_all_triggers_surfaces_the_dialogue(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": [{"speaker": "pnj", "text": "Salut !"}]}},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_1", "lines": [{"speaker": "pnj", "text": "Salut !"}]},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
result = screens.list_all_triggers(slug)
|
||||
assert result[0]["chain"] == [{"type": "dialogue", "dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]}}]
|
||||
assert result[0]["chain"] == [
|
||||
{
|
||||
"type": "dialogue",
|
||||
"dialogue": {"id": "d_1", "lines": [{"type": "dialogue", "speaker": "pnj", "text": "Salut !"}]},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_list_all_triggers_surfaces_a_chain_of_actions(client, tmp_game_slug_cleanup):
|
||||
def test_list_all_triggers_surfaces_a_chain_of_actions(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Chaînage (demande explicite : "pouvoir modifier un déclencheur pour
|
||||
lui ajouter des actions") : dialogue -> then variable, la chaîne
|
||||
ENTIÈRE est aplatie dans l'ordre d'exécution."""
|
||||
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": []},
|
||||
"then": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
result = screens.list_all_triggers(slug)
|
||||
assert result[0]["chain"] == [
|
||||
{"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
{"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"},
|
||||
{
|
||||
"type": "variable",
|
||||
"id": "v_1",
|
||||
"target_variable": "score",
|
||||
"data_operation": "incrementer",
|
||||
"data_value": "1",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(client, tmp_game_slug_cleanup):
|
||||
def test_list_completable_dialogue_ids_collects_mark_completed_dialogues_everywhere(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
direct_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
cond_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
other_id = _add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
_set_rules(slug, direct_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True},
|
||||
}])
|
||||
_set_rules(slug, cond_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition", "variable": "score", "operateur": "egal", "valeur": "1",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True},
|
||||
"si_faux": None,
|
||||
},
|
||||
}])
|
||||
_set_rules(slug, other_id, [{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
direct_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_direct", "lines": []}, "mark_completed": True},
|
||||
}
|
||||
],
|
||||
)
|
||||
_set_rules(
|
||||
slug,
|
||||
cond_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {
|
||||
"type": "condition",
|
||||
"variable": "score",
|
||||
"operateur": "egal",
|
||||
"valeur": "1",
|
||||
"si_vrai": {"type": "dialogue", "dialogue": {"id": "d_cond", "lines": []}, "mark_completed": True},
|
||||
"si_faux": None,
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
_set_rules(
|
||||
slug,
|
||||
other_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_not_completing", "lines": []}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
ids = screens.list_completable_dialogue_ids(slug)
|
||||
assert set(ids) == {"d_direct", "d_cond"}
|
||||
|
||||
|
||||
def test_trigger_dialogue_update_route_persists_the_new_lines(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_dialogue_update_route_persists_the_new_lines(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/dialogue",
|
||||
json={"dialogue_id": "d_1", "lines": [{"speaker": "pnj", "text": "Trouvé !"}]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules[0]["action"]["dialogue"]["lines"] == [{"type": "dialogue", "speaker": "pnj", "text": "Trouvé !"}]
|
||||
|
||||
|
||||
def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/dialogue",
|
||||
@@ -254,7 +380,7 @@ def test_trigger_dialogue_update_route_404s_for_an_unknown_dialogue_id(client, t
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game):
|
||||
def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client: FlaskClient, game: str) -> None:
|
||||
resp = client.post(
|
||||
f"/game/{game}/triggers/999999/dialogue",
|
||||
json={"dialogue_id": "d_1", "lines": []},
|
||||
@@ -262,138 +388,239 @@ def test_trigger_dialogue_update_route_404s_for_an_unknown_object(client, game):
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_append_action_route_attaches_a_then_at_the_end_of_the_chain(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}},
|
||||
json={
|
||||
"after_id": "d_1",
|
||||
"action": {
|
||||
"type": "variable",
|
||||
"id": "v_1",
|
||||
"target_variable": "score",
|
||||
"data_operation": "incrementer",
|
||||
"data_value": "1",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
rules = screens.resolve_collision_rules(obj)
|
||||
assert rules[0]["action"]["then"] == {"type": "variable", "id": "v_1", "target_variable": "score", "data_operation": "incrementer", "data_value": "1"}
|
||||
assert rules[0]["action"]["then"] == {
|
||||
"type": "variable",
|
||||
"id": "v_1",
|
||||
"target_variable": "score",
|
||||
"data_operation": "incrementer",
|
||||
"data_value": "1",
|
||||
}
|
||||
|
||||
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_after_id(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_after_id(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"trigger": "collision",
|
||||
"action": {"type": "dialogue", "dialogue": {"id": "d_1", "lines": []}},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_absent", "action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"}},
|
||||
json={
|
||||
"after_id": "d_absent",
|
||||
"action": {"type": "variable", "target_variable": "score", "data_operation": "incrementer"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then(client, tmp_game_slug_cleanup):
|
||||
def test_trigger_append_action_route_inserts_between_a_leaf_and_its_existing_then(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
"""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, [{
|
||||
"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"},
|
||||
},
|
||||
}])
|
||||
_set_rules(
|
||||
slug,
|
||||
obj_id,
|
||||
[
|
||||
{
|
||||
"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",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/triggers/{obj_id}/append-action",
|
||||
json={"after_id": "d_1", "action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"}},
|
||||
json={
|
||||
"after_id": "d_1",
|
||||
"action": {"type": "variable", "target_variable": "vies", "data_operation": "decrementer"},
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
obj = screens.get_scene_object(slug, obj_id)
|
||||
obj = not_none(screens.get_scene_object(slug, obj_id))
|
||||
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):
|
||||
def test_trigger_append_action_route_404s_for_an_unknown_object(client: FlaskClient, game: str) -> None:
|
||||
resp = client.post(f"/game/{game}/triggers/999999/append-action", json={"after_id": "d_1", "action": {}})
|
||||
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):
|
||||
def test_trigger_remove_action_route_removes_the_whole_rule_when_it_is_the_only_leaf(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_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)
|
||||
obj = not_none(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):
|
||||
def test_trigger_remove_action_route_404s_for_an_unknown_leaf_id(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_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):
|
||||
def test_trigger_move_action_route_swaps_a_leaf_up_in_its_chain(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
},
|
||||
}])
|
||||
_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)
|
||||
obj = not_none(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):
|
||||
def test_trigger_move_action_route_400s_for_an_invalid_direction(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
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": []}},
|
||||
}])
|
||||
_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):
|
||||
def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
"""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"},
|
||||
}])
|
||||
_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"]
|
||||
@@ -409,7 +636,9 @@ def test_triggers_get_route_backfills_and_persists_a_missing_leaf_id(client, tmp
|
||||
assert append_resp.status_code == 200
|
||||
|
||||
|
||||
def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game_slug_cleanup):
|
||||
def test_objects_catalog_lists_eligible_objects_with_thumbnails(
|
||||
client: FlaskClient, tmp_game_slug_cleanup: Callable[[str], str]
|
||||
) -> None:
|
||||
slug, screen_id = _create_jeu2d_game(client, tmp_game_slug_cleanup)
|
||||
_add_personnage(client, slug, screen_id, role="pnj")
|
||||
|
||||
@@ -420,10 +649,10 @@ def test_objects_catalog_lists_eligible_objects_with_thumbnails(client, tmp_game
|
||||
assert catalog[0]["thumbnail_html"]
|
||||
|
||||
|
||||
def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
|
||||
def test_triggers_route_is_isolated_like_other_game_routes(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre
|
||||
"""Même garde d'accès que /game/<slug>/publish (core/auth_guard.py) —
|
||||
aucune vérification supplémentaire écrite pour ces routes."""
|
||||
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
|
||||
from tests.test_auth import _cleanup_project, _complete_onboarding, _confirm_2fa, _register
|
||||
|
||||
_register(anon_client, "triggersisolation@example.com")
|
||||
_confirm_2fa(anon_client)
|
||||
@@ -441,7 +670,9 @@ def test_triggers_route_is_isolated_like_other_game_routes(anon_client):
|
||||
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"})
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user