first commit
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Fixtures pytest partagées.
|
||||
|
||||
Les tests utilisent le VRAI client de test Flask (`app.test_client()`) et de
|
||||
VRAIES bases SQLite créées sous projects/ (comme en production) — rien n'est
|
||||
simulé. Chaque jeu de test est créé avec un nom préfixé "pytest_" et son
|
||||
dossier est supprimé automatiquement à la fin du test (fixture `game`),
|
||||
pour ne jamais laisser de données de test dans projects/.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import pytest
|
||||
|
||||
from core.flask_app import app as flask_app
|
||||
from core import jinja_filters # noqa: F401 - enregistre les filtres Jinja (elabel, elstyle, colname)
|
||||
import routes # noqa: F401 - enregistre toutes les routes sur flask_app
|
||||
import db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_game_slug_cleanup():
|
||||
"""Pour les tests qui appellent db.create_game(...) directement (sans
|
||||
passer par une route Flask) : enregistre un slug à nettoyer à la fin du
|
||||
test, quel que soit son résultat."""
|
||||
slugs = []
|
||||
|
||||
def register(slug):
|
||||
slugs.append(slug)
|
||||
return slug
|
||||
|
||||
yield register
|
||||
for slug in slugs:
|
||||
game_dir = db.game_dir(slug)
|
||||
if os.path.isdir(game_dir):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
flask_app.config["TESTING"] = True
|
||||
with flask_app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def game(client):
|
||||
"""Crée un jeu de test frais et le supprime après le test, quel que
|
||||
soit son résultat (succès ou échec)."""
|
||||
resp = client.post("/games/new", data={"name": "pytest_test_game"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
slug = resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||
yield slug
|
||||
game_dir = db.game_dir(slug)
|
||||
if os.path.isdir(game_dir):
|
||||
shutil.rmtree(game_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mail_object(client, game):
|
||||
"""Un objet "Mail" avec deux champs texte (sujet, expediteur), pour les
|
||||
tests de Répéteur / élément de jeu lié à un objet."""
|
||||
resp = client.post(
|
||||
f"/game/{game}/objects/new",
|
||||
data={
|
||||
"object_name": "Mail",
|
||||
"field_name[]": ["sujet", "expediteur"],
|
||||
"field_type[]": ["texte", "texte"],
|
||||
"field_relation[]": ["", ""],
|
||||
"field_required[]": ["0", "0"],
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
definition_id = resp.headers["Location"].rstrip("/").split("/")[-1]
|
||||
return {"slug": game, "definition_id": int(definition_id)}
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests de la timeline d'animation : clips Animate.css ou personnalisés
|
||||
(images-clés), posés sur la piste d'un élément, avec un instant de départ
|
||||
et une durée sur l'axe du temps (secondes depuis l'affichage de l'écran)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_element(client, slug, screen_id, widget):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_animate_css_clip_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "titre")
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({
|
||||
"element_id": el_id, "kind": "animate_css", "animate_name": "bounce",
|
||||
"start_time": "1.5", "duration": "2", "delay": "0", "easing": "ease", "iteration_count": "1",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
clip = resp.get_json()
|
||||
assert clip["kind"] == "animate_css"
|
||||
assert clip["animate_name"] == "bounce"
|
||||
assert clip["start_time"] == 1.5
|
||||
assert clip["duration"] == 2.0
|
||||
assert clip["element_id"] == el_id
|
||||
|
||||
|
||||
def test_custom_keyframes_clip_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "conteneur")
|
||||
keyframes = [{"percent": 0, "props": {"opacity": "0"}}, {"percent": 100, "props": {"opacity": "1"}}]
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({
|
||||
"element_id": el_id, "kind": "custom", "custom_keyframes": keyframes,
|
||||
"start_time": "0", "duration": "3",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
clip = resp.get_json()
|
||||
assert clip["kind"] == "custom"
|
||||
assert json.loads(clip["custom_keyframes"]) == keyframes
|
||||
|
||||
|
||||
def test_clip_update_changes_timing(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
clip = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "fadeIn"}),
|
||||
content_type="application/json",
|
||||
).get_json()
|
||||
resp = client.post(
|
||||
f"/game/{game}/animations/clips/{clip['id']}/update",
|
||||
data=json.dumps({"start_time": "4", "duration": "0.5"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
updated = resp.get_json()
|
||||
assert updated["start_time"] == 4.0
|
||||
assert updated["duration"] == 0.5
|
||||
|
||||
|
||||
def test_clip_delete_removes_it(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
clip = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "tada"}),
|
||||
content_type="application/json",
|
||||
).get_json()
|
||||
resp = client.post(f"/game/{game}/animations/clips/{clip['id']}/delete")
|
||||
assert resp.status_code == 200
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert f'"id": {clip["id"]}' not in html
|
||||
|
||||
|
||||
def test_screen_edit_exposes_timeline_panel_and_animate_css_cdn(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "animPanel" in html
|
||||
assert "Timeline d'animation" in html
|
||||
assert "animate.css" in html
|
||||
|
||||
|
||||
def test_play_page_exposes_animation_runtime_and_cdn(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "animate.css" in html
|
||||
assert "runAnimationTimeline" in html
|
||||
assert "injectCustomKeyframes" in html
|
||||
|
||||
|
||||
def test_play_page_payload_includes_clip(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "titre")
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/animations/clips/add",
|
||||
data=json.dumps({"element_id": el_id, "kind": "animate_css", "animate_name": "pulse", "start_time": "2"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert '"animate_name": "pulse"' in html
|
||||
assert '"start_time": 2.0' in html
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Tests du bornage automatique des champs numériques (2.2)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _create_numeric_object(client, slug, field_name="reputation", min_v="0", max_v="100", start="50"):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": [field_name], "field_type[]": ["nombre_entier"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"],
|
||||
"field_min[]": [min_v], "field_max[]": [max_v],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
resp = client.post(f"/game/{slug}/objects/{def_id}/data/new", data={field_name: start})
|
||||
return def_id
|
||||
|
||||
|
||||
def _get_row_value(client, slug, def_id, field_name):
|
||||
import db
|
||||
definition = db.get_definition(slug, def_id)
|
||||
rows = db.list_rows(slug, definition)
|
||||
return rows[0].get(db.slugify(field_name).replace("-", "_"))
|
||||
|
||||
|
||||
def test_field_bounds_are_stored_at_creation(client, game):
|
||||
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "50")
|
||||
import db
|
||||
definition = db.get_definition(game, def_id)
|
||||
field = definition["fields"][0]
|
||||
assert field["min_value"] == 0
|
||||
assert field["max_value"] == 100
|
||||
|
||||
|
||||
def test_incrementer_clamps_to_max(client, game):
|
||||
"""Régression 2.2 : une action "Augmenter de..." ne doit jamais faire
|
||||
dépasser la borne maxi réglée sur le champ, même si le montant ajouté
|
||||
dépasse largement ce qu'il reste avant la limite."""
|
||||
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "95")
|
||||
row_value = _get_row_value(client, game, def_id, "reputation")
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_donnee",
|
||||
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
|
||||
"data_operation": "incrementer", "data_value": "50",
|
||||
}), content_type="application/json")
|
||||
node = resp.get_json()
|
||||
|
||||
resp = client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
|
||||
assert resp.get_json() == {"ok": True}
|
||||
|
||||
new_value = _get_row_value(client, game, def_id, "reputation")
|
||||
assert new_value == 100, f"la valeur aurait dû être bornée à 100, vaut {new_value}"
|
||||
|
||||
|
||||
def test_decrementer_clamps_to_min(client, game):
|
||||
"""Régression 2.2 : symétrique pour "Diminuer de..." et la borne mini."""
|
||||
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "5")
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_donnee",
|
||||
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
|
||||
"data_operation": "decrementer", "data_value": "50",
|
||||
}), content_type="application/json")
|
||||
node = resp.get_json()
|
||||
|
||||
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
|
||||
new_value = _get_row_value(client, game, def_id, "reputation")
|
||||
assert new_value == 0, f"la valeur aurait dû être bornée à 0, vaut {new_value}"
|
||||
|
||||
|
||||
def test_definir_nombre_also_clamped(client, game):
|
||||
"""Régression 2.2 : "Donner cette valeur (nombre)" doit aussi être
|
||||
bornée, pas seulement les incréments/décréments."""
|
||||
def_id = _create_numeric_object(client, game, "reputation", "0", "100", "50")
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_donnee",
|
||||
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
|
||||
"data_operation": "definir_nombre", "data_value": "9999",
|
||||
}), content_type="application/json")
|
||||
node = resp.get_json()
|
||||
|
||||
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
|
||||
new_value = _get_row_value(client, game, def_id, "reputation")
|
||||
assert new_value == 100
|
||||
|
||||
|
||||
def test_no_bounds_set_means_unlimited(client, game):
|
||||
"""Sans bornes réglées (champ laissé vide), le comportement doit rester
|
||||
celui d'avant 2.2 : aucune limite appliquée."""
|
||||
def_id = _create_numeric_object(client, game, "reputation", "", "", "50")
|
||||
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/flow/nodes/add", data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_donnee",
|
||||
"target_definition_id": def_id, "target_row_id": 1, "target_field": "reputation",
|
||||
"data_operation": "incrementer", "data_value": "500",
|
||||
}), content_type="application/json")
|
||||
node = resp.get_json()
|
||||
|
||||
client.post(f"/game/{game}/flow/nodes/{node['id']}/run-data")
|
||||
new_value = _get_row_value(client, game, def_id, "reputation")
|
||||
assert new_value == 550
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Tests des conditions combinées ET/OU (2.4).
|
||||
|
||||
Un nœud Condition peut porter plusieurs clauses (cond_clauses, JSON) combinées
|
||||
par cond_combinator ('et'/'ou'), en plus de sa clause "historique" unique
|
||||
(cond_definition_id/cond_field/...). Un nœud sans cond_clauses doit garder
|
||||
EXACTEMENT son comportement d'avant (une seule comparaison) — c'est ce que
|
||||
vérifient les tests de non-régression ci-dessous, en plus des tests dédiés
|
||||
à la nouvelle fonctionnalité.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _create_object_two_fields(client, slug):
|
||||
"""Crée un objet "Partie" avec deux champs numériques et une ligne."""
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie",
|
||||
"field_name[]": ["reputation", "niveau"],
|
||||
"field_type[]": ["nombre_entier", "nombre_entier"],
|
||||
"field_relation[]": ["", ""],
|
||||
"field_required[]": ["0", "0"],
|
||||
"field_min[]": ["", ""],
|
||||
"field_max[]": ["", ""],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"reputation": "5", "niveau": "3"})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_legacy_single_clause_condition_still_works_without_cond_clauses(client, game):
|
||||
"""Non-régression : un nœud Condition créé SANS cond_clauses (comme avant
|
||||
2.4) doit toujours fonctionner à l'identique."""
|
||||
screen_id = _create_screen(client, game)
|
||||
def_id = _create_object_two_fields(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "condition",
|
||||
"cond_definition_id": def_id, "cond_row_id": 1,
|
||||
"cond_field": "reputation", "cond_field_type": "nombre_entier",
|
||||
"cond_operator": "superieur", "cond_value": "10",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
node = resp.get_json()
|
||||
assert node["cond_clauses"] is None
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "evaluateConditionClause" in html
|
||||
assert "cond_clauses" in html
|
||||
|
||||
|
||||
def test_condition_node_persists_clauses_and_combinator(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
def_id = _create_object_two_fields(client, game)
|
||||
clauses = [
|
||||
{"definition_id": def_id, "row_id": 1, "field": "reputation", "field_type": "nombre_entier", "operator": "inferieur", "value": "20"},
|
||||
{"definition_id": def_id, "row_id": 1, "field": "niveau", "field_type": "nombre_entier", "operator": "superieur_egal", "value": "3"},
|
||||
]
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "condition",
|
||||
"cond_definition_id": def_id, "cond_row_id": 1,
|
||||
"cond_field": "reputation", "cond_field_type": "nombre_entier",
|
||||
"cond_operator": "inferieur", "cond_value": "20",
|
||||
"cond_clauses": json.dumps(clauses), "cond_combinator": "ou",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["cond_combinator"] == "ou"
|
||||
stored_clauses = json.loads(node["cond_clauses"])
|
||||
assert len(stored_clauses) == 2
|
||||
assert stored_clauses[0]["field"] == "reputation"
|
||||
assert stored_clauses[1]["field"] == "niveau"
|
||||
|
||||
|
||||
def test_condition_combinator_defaults_to_et_when_absent(client, game):
|
||||
"""cond_combinator a une valeur par défaut 'et' en base même si on ne la
|
||||
précise jamais (jeux migrés depuis avant 2.4)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
def_id = _create_object_two_fields(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "condition",
|
||||
"cond_definition_id": def_id, "cond_row_id": 1,
|
||||
"cond_field": "reputation", "cond_field_type": "nombre_entier",
|
||||
"cond_operator": "superieur", "cond_value": "10",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
node = resp.get_json()
|
||||
assert node["cond_combinator"] == "et"
|
||||
|
||||
|
||||
def test_play_page_exposes_combinator_logic(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "cond_combinator" in html
|
||||
assert "'ou'" in html
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Tests des points "Confort" (section 3 de la fiche de cadrage) :
|
||||
3.1 interactions au survol, 3.2 séquences temporisées, 3.3 surbrillance
|
||||
générique dynamique, 3.4 overlay/modale réutilisable, 3.5 verrouillage
|
||||
d'un élément après décision."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_element(client, slug, screen_id, widget):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
# ---------- 3.1 — Interactions au survol ----------
|
||||
|
||||
def test_hover_text_control_saved_and_rendered(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "titre")
|
||||
client.post(f"/game/{game}/elements/{el_id}/save", data={
|
||||
"ctrl_content": "Mathilde Dubois", "ctrl_survol_texte": "mathilde.d@forgebase.fr",
|
||||
})
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert 'data-hover-text="mathilde.d@forgebase.fr"' in html
|
||||
|
||||
|
||||
def test_hover_text_absent_by_default_no_regression(client, game):
|
||||
"""Sans réglage de survol, aucun attribut data-hover-text ne doit
|
||||
apparaître — aucune régression sur les éléments existants."""
|
||||
screen_id = _create_screen(client, game)
|
||||
_add_element(client, game, screen_id, "titre")
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
# (le mot "data-hover-text" apparaît dans un commentaire JS du moteur
|
||||
# lui-même — on vérifie donc l'absence de l'ATTRIBUT réellement posé sur
|
||||
# un élément, pas la simple présence de la chaîne dans la page)
|
||||
assert 'data-hover-text="' not in html
|
||||
|
||||
|
||||
def test_play_page_exposes_hover_binding_runtime(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "bindHoverTexts" in html
|
||||
|
||||
|
||||
# ---------- 3.2 — Séquences temporisées ----------
|
||||
|
||||
def test_attendre_action_node_persists_delay(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": "attendre", "data_value": "5"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["action_type"] == "attendre"
|
||||
assert node["data_value"] == "5"
|
||||
|
||||
|
||||
def test_play_page_exposes_wait_action_runtime(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'attendre'" in html
|
||||
assert "setTimeout" in html
|
||||
|
||||
|
||||
# ---------- 3.3 — Surbrillance générique dynamique ----------
|
||||
|
||||
def test_surbrillance_action_node_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_element",
|
||||
"target_element_id": el_id, "element_property": "surbrillance", "element_value": "on",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["element_property"] == "surbrillance"
|
||||
assert node["element_value"] == "on"
|
||||
|
||||
|
||||
def test_play_page_exposes_highlight_runtime_and_css(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "forgeHighlight" in html
|
||||
assert "'surbrillance'" in html
|
||||
|
||||
|
||||
# ---------- 3.4 — Overlay / modale réutilisable ----------
|
||||
|
||||
def test_overlay_widget_renders_fullscreen_fixed_box(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
overlay_id = _add_element(client, game, screen_id, "superposition")
|
||||
client.post(f"/game/{game}/elements/{overlay_id}/save", data={"ctrl_couleur_boite": "#222222", "ctrl_arrondi": "20"})
|
||||
client.post(f"/game/{game}/elements/{overlay_id}/children/add", data={"widget": "titre"})
|
||||
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
idx = html.find(f'data-element-id="{overlay_id}"')
|
||||
assert idx != -1
|
||||
# Le tag qui porte data-element-id est celui rendu par _render_overlay :
|
||||
# on regarde tout son contenu de balise ouvrante (jusqu'au ">" suivant)
|
||||
# ainsi que ce qui suit immédiatement (la boîte centrée à l'intérieur).
|
||||
tag_start = html.rfind("<div", 0, idx)
|
||||
snippet = html[tag_start:idx + 400]
|
||||
assert "position:fixed" in snippet
|
||||
assert "forgeOverlayBox" in snippet
|
||||
|
||||
|
||||
def test_overlay_starts_hidden_by_default(client, game):
|
||||
"""Régression : une Superposition fraîchement posée ne doit PAS couvrir
|
||||
tout l'écran dès sa création (position:fixed + inset:0 la ferait sinon
|
||||
intercepter tous les clics de l'écran) — elle démarre masquée, à ouvrir
|
||||
explicitement via une action."""
|
||||
screen_id = _create_screen(client, game)
|
||||
overlay_id = _add_element(client, game, screen_id, "superposition")
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
idx = html.find(f'data-element-id="{overlay_id}"')
|
||||
assert idx != -1
|
||||
tag_start = html.rfind("<div", 0, idx)
|
||||
snippet = html[tag_start:idx]
|
||||
assert "display:none" in snippet
|
||||
|
||||
|
||||
def test_overlay_can_be_hidden_and_shown_like_any_element(client, game):
|
||||
"""La fermeture manuelle de l'overlay réutilise l'action existante
|
||||
"Modifier un élément → Visibilité" — pas de mécanisme dédié."""
|
||||
screen_id = _create_screen(client, game)
|
||||
overlay_id = _add_element(client, game, screen_id, "superposition")
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_element",
|
||||
"target_element_id": overlay_id, "element_property": "visibilite", "element_value": "masque",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["element_property"] == "visibilite"
|
||||
assert node["target_element_id"] == overlay_id
|
||||
|
||||
|
||||
# ---------- 3.5 — Verrouillage d'un élément après décision ----------
|
||||
|
||||
def test_desactive_action_node_persists(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({
|
||||
"node_type": "action", "action_type": "modifier_element",
|
||||
"target_element_id": el_id, "element_property": "desactive", "element_value": "on",
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["element_property"] == "desactive"
|
||||
assert node["element_value"] == "on"
|
||||
|
||||
|
||||
def test_play_page_exposes_lock_runtime_and_css(client, game):
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "forgeDisabled" in html
|
||||
assert "pointer-events:none" in html
|
||||
assert "'desactive'" in html
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests unitaires purs de la couche données (db/) — sans passer par Flask."""
|
||||
import db
|
||||
|
||||
|
||||
def test_slugify_basic():
|
||||
assert db.slugify("Hello World") == "hello-world"
|
||||
assert db.slugify("Île de Ré !!") == "le-de-r"
|
||||
assert db.slugify("") == "jeu"
|
||||
|
||||
|
||||
def test_table_name_for():
|
||||
assert db.table_name_for("Personnage Principal") == "obj_personnage_principal"
|
||||
|
||||
|
||||
def test_create_game_and_meta(tmp_game_slug_cleanup):
|
||||
slug = db.create_game("Mon Jeu De Test")
|
||||
tmp_game_slug_cleanup(slug)
|
||||
meta = db.game_meta(slug)
|
||||
assert meta["name"] == "Mon Jeu De Test"
|
||||
assert slug in [g["slug"] for g in db.list_games()]
|
||||
|
||||
|
||||
def test_create_game_deduplicates_slug(tmp_game_slug_cleanup):
|
||||
slug1 = db.create_game("Doublon")
|
||||
tmp_game_slug_cleanup(slug1)
|
||||
slug2 = db.create_game("Doublon")
|
||||
tmp_game_slug_cleanup(slug2)
|
||||
assert slug1 != slug2
|
||||
|
||||
|
||||
def test_definition_crud(tmp_game_slug_cleanup):
|
||||
slug = db.create_game("Objets Test")
|
||||
tmp_game_slug_cleanup(slug)
|
||||
|
||||
definition_id = db.create_definition(
|
||||
slug, "Personnage",
|
||||
[{"name": "nom", "type": "texte", "required": True},
|
||||
{"name": "pv", "type": "nombre_entier", "required": False}],
|
||||
)
|
||||
definition = db.get_definition(slug, definition_id)
|
||||
assert definition["name"] == "Personnage"
|
||||
assert {f["name"] for f in definition["fields"]} == {"nom", "pv"}
|
||||
|
||||
db.insert_row(slug, definition, {"nom": "Aldric", "pv": "100"})
|
||||
rows = db.list_rows(slug, definition)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["nom"] == "Aldric"
|
||||
assert rows[0]["pv"] == 100
|
||||
|
||||
row_id = rows[0]["id"]
|
||||
db.update_row(slug, definition, row_id, {"nom": "Aldric le Brave", "pv": "80"})
|
||||
updated = db.get_row(slug, definition, row_id)
|
||||
assert updated["nom"] == "Aldric le Brave"
|
||||
assert updated["pv"] == 80
|
||||
|
||||
field_def = next(f for f in definition["fields"] if f["name"] == "pv")
|
||||
db.update_row_field(slug, definition, row_id, field_def, 42)
|
||||
assert db.get_row(slug, definition, row_id)["pv"] == 42
|
||||
# update_row_field ne doit toucher qu'à cette seule colonne
|
||||
assert db.get_row(slug, definition, row_id)["nom"] == "Aldric le Brave"
|
||||
|
||||
db.delete_row(slug, definition, row_id)
|
||||
assert db.list_rows(slug, definition) == []
|
||||
|
||||
db.delete_definition(slug, definition_id)
|
||||
assert db.get_definition(slug, definition_id) is None
|
||||
|
||||
|
||||
def test_relation_blocks_deletion_reference_check(tmp_game_slug_cleanup):
|
||||
slug = db.create_game("Relations Test")
|
||||
tmp_game_slug_cleanup(slug)
|
||||
lieu_id = db.create_definition(slug, "Lieu", [{"name": "nom", "type": "texte"}])
|
||||
perso_id = db.create_definition(
|
||||
slug, "Personnage",
|
||||
[{"name": "nom", "type": "texte"}, {"name": "lieu", "type": "relation", "relation_definition_id": lieu_id}],
|
||||
)
|
||||
refs = db.definitions_referencing(slug, lieu_id)
|
||||
assert any(r["id"] == perso_id for r in refs)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Tests de l'éditeur de flow à nœuds (déclencheur / condition / action)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_flow_node_and_edge_crud(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "bouton"})
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_element_id": element_id, "trigger_event": "clic"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
trigger_node = resp.get_json()
|
||||
assert trigger_node["node_type"] == "trigger"
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": "ecran_suivant"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
action_node = resp.get_json()
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/edges/add",
|
||||
data=json.dumps({"from_node_id": trigger_node["id"], "from_port": "out", "to_node_id": action_node["id"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert str(trigger_node["id"]) in html
|
||||
|
||||
|
||||
def test_multiple_actions_from_one_trigger(client, game):
|
||||
"""Régression : un déclencheur doit pouvoir avoir PLUSIEURS arêtes
|
||||
sortantes (plusieurs actions exécutées au même clic)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "bouton"})
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_element_id": element_id, "trigger_event": "clic"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
trigger_node = resp.get_json()
|
||||
|
||||
action_ids = []
|
||||
for action_type in ("ecran_suivant", "rien"):
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": action_type}),
|
||||
content_type="application/json",
|
||||
)
|
||||
action_ids.append(resp.get_json()["id"])
|
||||
|
||||
for aid in action_ids:
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/edges/add",
|
||||
data=json.dumps({"from_node_id": trigger_node["id"], "from_port": "out", "to_node_id": aid}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
import screens
|
||||
edges = screens.list_flow_edges(game, screen_id)
|
||||
from_trigger = [e for e in edges if e["from_node_id"] == trigger_node["id"]]
|
||||
assert len(from_trigger) == 2
|
||||
|
||||
|
||||
def test_repeater_rows_carry_stable_row_id(client, game, mail_object):
|
||||
"""Régression 1.1 : contrairement à data-element-id (l'id du MODÈLE de
|
||||
ligne, identique sur chaque ligne rendue), chaque ligne d'un Répéteur
|
||||
doit porter un data-row-id qui identifie la VRAIE ligne de données
|
||||
affichée — sans ça, un déclencheur posé sur le Répéteur ne peut pas
|
||||
savoir laquelle de ses lignes a été cliquée."""
|
||||
slug, definition_id = mail_object["slug"], mail_object["definition_id"]
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"})
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"})
|
||||
|
||||
screen_id = _create_screen(client, slug)
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"})
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{slug}/elements/{rep_id}/save", data={"ctrl_definition_id": str(definition_id)})
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'class="repeaterItem" data-row-id="' in html
|
||||
assert f'data-definition-id="{definition_id}"' in html
|
||||
# Deux lignes de données -> deux data-row-id DIFFÉRENTS.
|
||||
row_ids = re.findall(r'data-row-id="(\d+)"', html)
|
||||
assert len(row_ids) == 2
|
||||
assert len(set(row_ids)) == 2
|
||||
|
||||
|
||||
def test_ouvrir_ligne_action_node_persists_target_screen(client, game):
|
||||
"""Régression 1.1 : la nouvelle action "Ouvrir la ligne de Répéteur
|
||||
cliquée" doit accepter un écran cible comme l'action "aller_a", pour
|
||||
que l'écran de détail (ex: lecture d'un mail) puisse être choisi dans
|
||||
l'éditeur de logique."""
|
||||
import json
|
||||
|
||||
inbox_id = _create_screen(client, game, "Boîte de réception")
|
||||
detail_id = _create_screen(client, game, "Détail du mail")
|
||||
resp = client.post(f"/game/{game}/screens/{inbox_id}/elements/add", data={"widget": "repeteur_donnees"})
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{inbox_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_element_id": rep_id, "trigger_event": "clic"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
trigger_node = resp.get_json()
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{inbox_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": "ouvrir_ligne", "target_screen_id": detail_id}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
action_node = resp.get_json()
|
||||
assert action_node["action_type"] == "ouvrir_ligne"
|
||||
assert action_node["target_screen_id"] == detail_id
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{inbox_id}/flow/edges/add",
|
||||
data=json.dumps({"from_node_id": trigger_node["id"], "from_port": "out", "to_node_id": action_node["id"]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_play_page_exposes_row_bindings_helpers(client, game):
|
||||
"""Le runtime jouable doit embarquer la logique côté client capable de
|
||||
résoudre les {{champ}} de la ligne ouverte (voir README, section
|
||||
"Cliquer une ligne de Répéteur") — vérifie que le JS livré au joueur
|
||||
contient bien les briques attendues, sans dépendre d'un navigateur."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "applyOpenRowBindings" in html
|
||||
assert "lastClickedRowId" in html
|
||||
assert "'ouvrir_ligne'" in html
|
||||
|
||||
|
||||
def test_repeater_filter_literal_value(client, game, mail_object):
|
||||
"""Régression 1.2 : un filtre réglé sur le Répéteur (champ + opérateur +
|
||||
valeur fixe) ne doit garder que les lignes qui correspondent, sur un
|
||||
écran UNIQUE (pas un écran par niveau) — voir claude/forge-engine-lacunes-boitemail.md."""
|
||||
slug, definition_id = mail_object["slug"], mail_object["definition_id"]
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"})
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"})
|
||||
|
||||
screen_id = _create_screen(client, slug)
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"})
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{slug}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(definition_id), "ctrl_content": "{{sujet}}",
|
||||
"ctrl_filtre_champ": "sujet", "ctrl_filtre_operateur": "egal", "ctrl_filtre_valeur": "Sujet 2",
|
||||
})
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode()
|
||||
rows = re.findall(r'<div class="repeaterItem"[^>]*>(.*?)</div>', html)
|
||||
assert len(rows) == 1, f"le filtre aurait dû ne garder qu'UNE ligne, en a laissé {len(rows)}"
|
||||
assert "Sujet 2" in rows[0]
|
||||
|
||||
|
||||
def test_repeater_filter_reference_to_another_object(client, game, mail_object):
|
||||
"""Régression 1.2 : le filtre doit aussi savoir comparer à une valeur qui
|
||||
change en cours de partie via "{{NomObjet.champ}}" (ex: {{Partie.niveau}}),
|
||||
pas seulement à une valeur fixe — c'est ce qui permet à UN SEUL écran de
|
||||
s'adapter à la progression sans dupliquer un écran par niveau."""
|
||||
slug, definition_id = mail_object["slug"], mail_object["definition_id"]
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"})
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"})
|
||||
|
||||
# Objet "Partie" à une seule ligne, jouant le rôle d'état global de partie.
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": ["sujet_actif"], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"],
|
||||
}, follow_redirects=False)
|
||||
partie_def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{partie_def_id}/data/new", data={"sujet_actif": "Sujet 1"})
|
||||
|
||||
screen_id = _create_screen(client, slug)
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": "repeteur_donnees"})
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{slug}/elements/{rep_id}/save", data={
|
||||
"ctrl_definition_id": str(definition_id), "ctrl_content": "{{sujet}}",
|
||||
"ctrl_filtre_champ": "sujet", "ctrl_filtre_operateur": "egal",
|
||||
"ctrl_filtre_valeur": "{{Partie.sujet_actif}}",
|
||||
})
|
||||
|
||||
html = client.get(f"/game/{slug}/screens/{screen_id}/edit").data.decode()
|
||||
rows = re.findall(r'<div class="repeaterItem"[^>]*>(.*?)</div>', html)
|
||||
assert len(rows) == 1, f"le filtre aurait dû ne garder qu'UNE ligne, en a laissé {len(rows)}"
|
||||
assert "Sujet 1" in rows[0]
|
||||
|
||||
|
||||
def test_affichage_trigger_does_not_require_element(client, game):
|
||||
"""Régression 1.2 : le nouveau déclencheur "affichage" doit pouvoir être
|
||||
créé SANS élément cible (il concerne l'écran entier), contrairement au
|
||||
déclencheur "clic" — voir screens/flow/constants.py."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "trigger", "trigger_event": "affichage"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["trigger_event"] == "affichage"
|
||||
assert node["trigger_element_id"] is None
|
||||
|
||||
|
||||
def test_play_page_exposes_screen_show_trigger_helper(client, game):
|
||||
"""Le runtime jouable doit exécuter les nœuds "affichage" à chaque fois
|
||||
qu'un écran est montré (premier affichage, retour en arrière, données
|
||||
modifiées) — voir README, section correspondante."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "runScreenShowTriggers" in html
|
||||
assert "'affichage'" in html
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Tests du widget Jauge (2.1 : binding live d'une donnée vers un style)."""
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _create_partie_object(client, slug, field_name="reputation", value="50"):
|
||||
resp = client.post(f"/game/{slug}/objects/new", data={
|
||||
"object_name": "Partie", "field_name[]": [field_name], "field_type[]": ["texte"],
|
||||
"field_relation[]": [""], "field_required[]": ["0"],
|
||||
}, follow_redirects=False)
|
||||
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
|
||||
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={field_name: value})
|
||||
return def_id
|
||||
|
||||
|
||||
def test_gauge_width_reflects_field_value_without_extra_action(client, game):
|
||||
"""Régression 2.1 : la largeur de la jauge doit refléter automatiquement
|
||||
la valeur du champ suivi, sans qu'aucune action "Modifier un élément"
|
||||
n'ait été posée dans le graphe de logique."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "50")
|
||||
screen_id = _create_screen(client, game)
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "width:50.0%" in html
|
||||
|
||||
|
||||
def test_gauge_updates_after_data_change_with_no_extra_wiring(client, game):
|
||||
"""Régression 2.1 : quand la donnée change (ex: une action "Modifier une
|
||||
donnée" exécutée en jeu), la jauge doit refléter la NOUVELLE valeur au
|
||||
prochain rendu — sans binding manuel à recâbler."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "50")
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
|
||||
# Simule une action "Modifier une donnée" qui change la valeur en base.
|
||||
client.post(f"/game/{game}/objects/{def_id}/data/new", data={"reputation": "90"})
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "width:90.0%" in html
|
||||
|
||||
|
||||
def test_gauge_color_gradient_shifts_between_low_and_high(client, game):
|
||||
"""Régression 2.1 : la couleur de remplissage doit se rapprocher de la
|
||||
couleur "maxi" quand la valeur est haute, pas rester figée."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "100")
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
"ctrl_couleur_basse": "#ff0000", "ctrl_couleur_haute": "#00ff00",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "background-color:#00ff00" in html # valeur au max -> couleur "haute" pure
|
||||
|
||||
|
||||
def test_gauge_value_clamped_visually_when_out_of_range(client, game):
|
||||
"""Une valeur au-delà du maxi réglé (avant que 2.2 n'empêche ça à la
|
||||
source) ne doit pas casser l'affichage — la jauge doit rester à 100%,
|
||||
pas déborder."""
|
||||
def_id = _create_partie_object(client, game, "reputation", "500")
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "jauge"}, follow_redirects=False)
|
||||
jauge_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{jauge_id}/save", data={
|
||||
"ctrl_definition_id": str(def_id), "ctrl_champ": "reputation", "ctrl_min": "0", "ctrl_max": "100",
|
||||
})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "width:100.0%" in html
|
||||
|
||||
|
||||
# ---------- Régression : clics multipliés après un rafraîchissement de
|
||||
# données (bouton +10/-10 relié à une jauge) ----------
|
||||
|
||||
def test_play_page_click_binding_guards_against_duplicate_listeners(client, game):
|
||||
"""Bug remonté par l'utilisateur : après plusieurs clics sur un bouton
|
||||
relié à une action "Modifier une donnée" (ex: +10 points), l'effet
|
||||
devenait instable (parfois -20, 0, +100 au lieu de ±10 par clic).
|
||||
|
||||
Cause : bindClicks() est rappelée après CHAQUE refreshRuntimeData() (donc
|
||||
après chaque donnée modifiée), et un bouton ordinaire (ni Répéteur, ni
|
||||
Jauge) garde le MÊME nœud DOM d'un rafraîchissement à l'autre — sans
|
||||
garde-fou, addEventListener('click') s'empile à chaque rafraîchissement,
|
||||
et un seul clic finit par déclencher l'action plusieurs fois d'affilée.
|
||||
|
||||
On vérifie ici la présence du garde-fou (clickBound) qui empêche ce
|
||||
ré-attachement — un vrai test de non-régression comportementale (clics
|
||||
répétés via un navigateur réel) a été exécuté manuellement pour valider
|
||||
le correctif : 5 clics sur +10 -> exactement +50, jamais plus."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "clickBound" in html
|
||||
assert "if (elDiv.dataset.clickBound) return;" in html
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Tests du panneau à onglets / visibilité mutuellement exclusive (2.3)."""
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_activer_onglet_action_node_persists_target_element(client, game):
|
||||
"""Régression 2.3 : le nouveau type d'action doit accepter un élément
|
||||
cible comme "modifier_element", sans propriété/valeur associées (tout
|
||||
est déduit automatiquement au clic côté jouable : montrer CET élément,
|
||||
masquer ses frères)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||
parent_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
child_ids = []
|
||||
for _ in range(3):
|
||||
resp = client.post(f"/game/{game}/elements/{parent_id}/children/add", data={"widget": "titre"})
|
||||
child_ids.append(int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1)))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
|
||||
data=json.dumps({"node_type": "action", "action_type": "activer_onglet", "target_element_id": child_ids[1]}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
node = resp.get_json()
|
||||
assert node["action_type"] == "activer_onglet"
|
||||
assert node["target_element_id"] == child_ids[1]
|
||||
|
||||
|
||||
def test_play_page_exposes_activer_onglet_runtime(client, game):
|
||||
"""Le runtime jouable doit savoir exécuter "activer_onglet" (montrer un
|
||||
élément, masquer ses frères) — vérifie que le JS livré au joueur
|
||||
contient bien cette logique, sans dépendre d'un navigateur."""
|
||||
resp = client.get(f"/game/{game}/play")
|
||||
html = resp.data.decode()
|
||||
assert "'activer_onglet'" in html
|
||||
assert "parentKey" in html
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests des corrections suite au retour utilisateur du 21/08 :
|
||||
- le format d'aperçu (Portrait/Paysage/Carré) d'un écran est mémorisé
|
||||
côté serveur, au lieu de repartir sur Portrait à chaque fois que le
|
||||
panneau est redemandé au serveur (ajout/suppression d'élément,
|
||||
changement de sélection) ;
|
||||
- l'échelle (transform:scale) d'un élément posé directement sur un écran
|
||||
vit sur un cadre intermédiaire (.canvasElementInner / .playElementInner),
|
||||
à la taille exacte du cadre de positionnement/animation (.canvasElement /
|
||||
.playElement) mais distinct de lui — ni sur la balise intérieure générée
|
||||
par render_element_html (sinon l'effet restait visuellement "coincé" à
|
||||
l'intérieur d'un cadre immobile), ni sur le cadre extérieur lui-même
|
||||
(sinon ce "transform" entrait en collision avec celui posé par Animate.css
|
||||
pendant une animation, qui l'écrasait) ;
|
||||
- cette même zone de jeu garde la proportion de l'écran en mode Jouer
|
||||
(voir #playFrame dans play.html), pour que les positions/tailles en %
|
||||
ne se déforment pas selon la fenêtre du joueur."""
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def _add_element(client, slug, screen_id, widget):
|
||||
resp = client.post(f"/game/{slug}/screens/{screen_id}/elements/add", data={"widget": widget}, follow_redirects=False)
|
||||
return int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_screen_defaults_to_portrait(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:9/16;' in html
|
||||
assert 'aspectBtn active" data-aspect="9/16"' in html
|
||||
|
||||
|
||||
def test_aspect_choice_persists_across_reload(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "16/9"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:16/9;' in html
|
||||
assert 'aspectBtn active" data-aspect="16/9"' in html
|
||||
|
||||
|
||||
def test_aspect_choice_survives_element_add_and_delete(client, game):
|
||||
"""Le vrai bug rapporté : l'écran repartait sur Portrait dès qu'on
|
||||
posait ou supprimait un élément, car ces deux actions redemandent le
|
||||
panneau au serveur."""
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "1/1"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
el_id = _add_element(client, game, screen_id, "bouton")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:1/1;' in html
|
||||
|
||||
client.post(f"/game/{game}/elements/{el_id}/delete", follow_redirects=False)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:1/1;' in html
|
||||
|
||||
|
||||
def test_unknown_aspect_value_is_ignored(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "not-a-ratio"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert 'aspect-ratio:9/16;' in html
|
||||
|
||||
|
||||
def test_scale_lives_on_outer_frame_for_top_level_element(client, game):
|
||||
"""Un conteneur posé directement sur l'écran : l'échelle doit être sur
|
||||
le cadre intermédiaire .canvasElementInner, pas sur le cadre extérieur
|
||||
.canvasElement (id="el-<id>", cible d'Animate.css — un "transform" posé
|
||||
là entrerait en collision avec celui d'une animation en cours) ni sur la
|
||||
balise intérieure générée par render_element_html (sinon l'effet restait
|
||||
visuellement clippé à l'intérieur d'un cadre qui, lui, ne grossissait
|
||||
pas)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
el_id = _add_element(client, game, screen_id, "conteneur")
|
||||
client.post(
|
||||
f"/game/{game}/elements/{el_id}/save",
|
||||
data={"ctrl_scale": "1.5"},
|
||||
)
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
# Le cadre extérieur (id="el-<id>", cible Animate.css) ne porte PAS le transform...
|
||||
outer_match = re.search(rf'id="el-{el_id}"[^>]*style="([^"]*)"', html)
|
||||
assert outer_match, "cadre .canvasElement introuvable"
|
||||
assert "transform" not in outer_match.group(1)
|
||||
# ...le cadre intermédiaire .canvasElementInner le porte...
|
||||
inner_html = html.split(f'id="el-{el_id}"', 1)[1]
|
||||
canvas_inner_match = re.search(r'class="canvasElementInner" style="([^"]*)"', inner_html)
|
||||
assert canvas_inner_match, "cadre .canvasElementInner introuvable"
|
||||
assert "transform:scale(1.5)" in canvas_inner_match.group(1)
|
||||
# ...et la balise intérieure (générée par render_element_html) ne le
|
||||
# répète pas une seconde fois.
|
||||
content_style_match = re.search(r'class="canvasElementInner"[^>]*>\s*<div style="([^"]*)"', inner_html)
|
||||
assert content_style_match, "balise intérieure introuvable"
|
||||
assert "transform" not in content_style_match.group(1)
|
||||
|
||||
|
||||
def test_scale_stays_on_own_tag_for_nested_element(client, game):
|
||||
"""Un élément posé À L'INTÉRIEUR d'un conteneur n'a pas de cadre séparé
|
||||
— sa propre balise EST déjà tout l'élément, donc l'échelle y reste."""
|
||||
screen_id = _create_screen(client, game)
|
||||
parent_id = _add_element(client, game, screen_id, "conteneur")
|
||||
resp = client.post(f"/game/{game}/elements/{parent_id}/children/add", data={"widget": "bouton"}, follow_redirects=False)
|
||||
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_scale": "1.3"})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "transform:scale(1.3)" in html
|
||||
|
||||
|
||||
def test_play_payload_includes_preview_aspect_per_screen(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
client.post(
|
||||
f"/game/{game}/screens/{screen_id}/aspect",
|
||||
data='{"aspect": "16/9"}',
|
||||
content_type="application/json",
|
||||
)
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert '"preview_aspect": "16/9"' in html
|
||||
assert 'data-aspect="16/9"' in html
|
||||
|
||||
|
||||
def test_play_page_has_letterboxing_frame(client, game):
|
||||
html = client.get(f"/game/{game}/play").data.decode()
|
||||
assert 'id="playFrame"' in html
|
||||
assert "applyScreenAspect" in html
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests des routes écrans / éléments posés sur un écran (via le client de
|
||||
test Flask, sur une vraie base SQLite du jeu de test)."""
|
||||
import re
|
||||
|
||||
|
||||
def _create_screen(client, slug, name="Accueil"):
|
||||
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
|
||||
|
||||
|
||||
def test_screen_crud(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 200
|
||||
assert b"Accueil" in resp.data
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/rename", data={"name": "Menu"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert b"Menu" in resp.data
|
||||
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/delete", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
resp = client.get(f"/game/{game}/screens/{screen_id}/edit")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_add_element_and_save_properties(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add",
|
||||
data={"widget": "titre"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
element_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{game}/elements/{element_id}/save",
|
||||
data={"ctrl_content": "Bienvenue", "ctrl_niveau": "1"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Bienvenue" in html
|
||||
|
||||
|
||||
def test_container_child_flex_row_no_collision(client, game):
|
||||
"""Régression : un conteneur en disposition 'ligne' ne doit plus forcer
|
||||
chacun de ses enfants à 100% de largeur (voir _style_string /
|
||||
_parent_flex_direction) — sinon deux enfants entrent en collision."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(
|
||||
f"/game/{game}/elements/{container_id}/save",
|
||||
data={"ctrl_disposition_interne": "ligne"},
|
||||
)
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
|
||||
child1_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "texte"})
|
||||
child2_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
idx1 = html.find(f'id="elt-{child1_id}"')
|
||||
idx2 = html.find(f'id="elt-{child2_id}"')
|
||||
style1 = html[max(0, idx1 - 400):idx1]
|
||||
style2 = html[max(0, idx2 - 400):idx2]
|
||||
# Aucun des deux enfants d'un conteneur en LIGNE ne doit avoir
|
||||
# width:100% forcé (ce qui causait la collision avant correction).
|
||||
assert "width:100%;" not in style1.split('style="')[-1] if 'style="' in style1 else True
|
||||
assert "width:auto" in style1 or "width:auto" in style2
|
||||
|
||||
|
||||
def test_element_type_template_case_a_and_case_b(client, game, mail_object):
|
||||
slug, definition_id = mail_object["slug"], mail_object["definition_id"]
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 1", "expediteur": "a@test.com"})
|
||||
client.post(f"/game/{slug}/objects/{definition_id}/data/new", data={"sujet": "Sujet 2", "expediteur": "b@test.com"})
|
||||
|
||||
resp = client.post(
|
||||
f"/game/{slug}/element-types",
|
||||
data={"name": "CarteMail", "icon": "📧", "bound_definition_id": str(definition_id)},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
html = client.get(f"/game/{slug}/element-types").data.decode()
|
||||
m = re.search(r"/screens/(\d+)/edit", html)
|
||||
assert m, "le lien vers l'écran-modèle doit apparaître dans le catalogue"
|
||||
template_screen_id = int(m.group(1))
|
||||
|
||||
# L'écran-modèle est un écran caché (is_template) : absent de la liste
|
||||
# normale des écrans jouables.
|
||||
screens_html = client.get(f"/game/{slug}/screens").data.decode()
|
||||
assert "CarteMail" not in screens_html or "Modèle" not in screens_html
|
||||
|
||||
# Construit le contenu du modèle : un titre lié au champ "sujet".
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{template_screen_id}/elements/add",
|
||||
data={"widget": "titre"},
|
||||
)
|
||||
titre_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{slug}/elements/{titre_id}/save", data={"ctrl_content": "{{sujet}}"})
|
||||
|
||||
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
|
||||
|
||||
# ---- Case A : posé directement sur un écran réel -> copie indépendante ----
|
||||
real_screen_id = _create_screen(client, slug, "Accueil")
|
||||
resp = client.post(
|
||||
f"/game/{slug}/screens/{real_screen_id}/elements/add",
|
||||
data={"widget": "__catalogue__", "element_type_id": str(element_type_id)},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
html = client.get(f"/game/{slug}/screens/{real_screen_id}/edit").data.decode()
|
||||
assert "{{sujet}}" in html # non résolu hors répéteur : limitation connue et documentée
|
||||
|
||||
# ---- Case B : modèle de ligne d'un Répéteur -> résolu en direct par ligne ----
|
||||
resp = client.post(f"/game/{slug}/screens/{real_screen_id}/elements/add", data={"widget": "repeteur_donnees"})
|
||||
rep_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(
|
||||
f"/game/{slug}/elements/{rep_id}/save",
|
||||
data={"ctrl_definition_id": str(definition_id), "ctrl_template_type_id": str(element_type_id)},
|
||||
)
|
||||
html = client.get(f"/game/{slug}/screens/{real_screen_id}/edit").data.decode()
|
||||
assert "Sujet 1" in html
|
||||
assert "Sujet 2" in html
|
||||
|
||||
|
||||
def test_unrelated_property_save_does_not_bake_in_default_background(client, game):
|
||||
"""Régression : changer un réglage (ex. la disposition interne) via le
|
||||
panneau de propriétés ne doit PAS écrire la couleur de fond par défaut
|
||||
du conteneur dans son style — cette valeur n'est qu'un aperçu affiché
|
||||
dans le sélecteur de couleur tant qu'elle n'a pas été choisie
|
||||
explicitement. Avant le correctif, comme tous les réglages du widget
|
||||
sont envoyés dans UN SEUL formulaire, sauvegarder n'importe lequel
|
||||
d'entre eux figeait aussi la couleur par défaut (#1f2430)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"}, follow_redirects=False
|
||||
)
|
||||
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html_before = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
|
||||
assert "background-color" not in html_before
|
||||
|
||||
# On ne touche qu'au préréglage de disposition, rien d'autre.
|
||||
client.post(
|
||||
f"/game/{game}/elements/{container_id}/save",
|
||||
data={"ctrl_disposition_interne": "ligne"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
html_after = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
|
||||
assert "flex-direction:row" in html_after
|
||||
assert "background-color" not in html_after, (
|
||||
"La couleur de fond par défaut a été écrite alors qu'elle n'a jamais été choisie."
|
||||
)
|
||||
|
||||
# Si on choisit vraiment une couleur, elle doit en revanche être conservée.
|
||||
client.post(
|
||||
f"/game/{game}/elements/{container_id}/save",
|
||||
data={"ctrl_disposition_interne": "ligne", "ctrl_background": "#ff0000"},
|
||||
follow_redirects=False,
|
||||
)
|
||||
html_custom = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
|
||||
assert "background-color:#ff0000" in html_custom
|
||||
|
||||
|
||||
def test_image_gets_object_fit_cover_by_default(client, game):
|
||||
"""Régression : une image fraîchement posée doit démarrer avec
|
||||
object-fit:cover (pour se recadrer proprement une fois redimensionnée)
|
||||
même si le panneau de propriétés n'a jamais été enregistré — ce réglage
|
||||
ne doit pas dépendre du même mécanisme de sauvegarde que la couleur de
|
||||
fond (voir test ci-dessus), sous peine de régresser vers le
|
||||
comportement par défaut du navigateur (image étirée/déformée)."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(
|
||||
f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "image"}, follow_redirects=False
|
||||
)
|
||||
assert resp.status_code == 302
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "object-fit:cover" in html
|
||||
|
||||
|
||||
def test_delete_element_type_removes_hidden_template_screen(client, game):
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
|
||||
html = client.get(f"/game/{game}/element-types").data.decode()
|
||||
m = re.search(r"/screens/(\d+)/edit", html)
|
||||
template_screen_id = int(m.group(1))
|
||||
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1))
|
||||
|
||||
assert client.get(f"/game/{game}/screens/{template_screen_id}/edit").status_code == 200
|
||||
client.post(f"/game/{game}/element-types/{element_type_id}/delete")
|
||||
assert client.get(f"/game/{game}/screens/{template_screen_id}/edit").status_code == 404
|
||||
Reference in New Issue
Block a user