first commit
Build and deploy / build-and-push (push) Successful in 17s
Build and deploy / deploy (push) Successful in 10s

This commit is contained in:
william
2026-08-21 16:23:49 +02:00
committed by william
commit 3f4ebc4527
248 changed files with 10180 additions and 0 deletions
+79
View File
@@ -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)}
+115
View File
@@ -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
+110
View File
@@ -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
+108
View File
@@ -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
+179
View File
@@ -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
+78
View File
@@ -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)
+230
View File
@@ -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
+107
View File
@@ -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
+42
View File
@@ -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
+140
View File
@@ -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
+194
View File
@@ -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