Files
Forge-Engine/tests/test_jauge.py
T
williamandClaude Sonnet 5 dbada333d5 Phase -1 : découpe le moteur de play.html en modules JS + premiers tests JS
templates/play.html était un unique fichier HTML+CSS+JS de 1069 lignes,
tout le moteur de jeu vivant dans UN SEUL <script>, sans aucune
couverture de test sur cette logique (seuls le rendu HTML et la syntaxe
JS étaient vérifiés). La feuille de route à venir (état par joueur,
hasard, clavier/minuteur, position/collision, son — voir le plan) va
justement faire grossir ce moteur : "un fichier = une fonction, un
dossier = une responsabilité" s'applique aussi au JS, pas seulement au
Python — le moment de découper est avant d'ajouter encore plus de code,
pas après.

Découpage en 6 fichiers sous static/js/play/, calqués sur les sections
déjà présentes dans le code (aucune réorganisation de logique, une pure
extraction) : screens.js (affichage d'écran, timeline d'animation),
conditions.js (évaluation des conditions — la partie 100% PURE, sans
DOM, la plus testable), actions.js (exécution des actions), triggers.js
(recherche des nœuds déclencheurs, attache des écouteurs), bindings.js
(résolution des {{champ}}, rafraîchissement des données), flow-engine.js
(parcours du graphe, événements personnalisés).

Zéro nouvel outillage : plusieurs <script src> dans l'ordre, partageant
le même espace global qu'avant (aucun bundler, aucune étape de build).
Les 2 URLs de route dont ces fichiers ont besoin (flow_node_run_data/
run_variable, runtime_payload) ne peuvent plus être injectées par Jinja
directement dans le code (un fichier statique n'est jamais passé par le
moteur de templates) — elles sont maintenant posées une fois dans
window.FORGE_PLAY_URLS par le petit <script> inline restant dans
play.html, qui ne porte plus que les données Jinja (gameData) et
l'amorçage (bindClicks() etc. au chargement).

publish/build_package.py : ajoute static/js/play à la liste des fichiers
copiés dans l'exécutable exporté (le mode jouable en dépend désormais).

Premiers tests JS (static/js/play/__tests__/conditions.test.js, lancés
via `node --test`, zéro nouvelle dépendance npm — decision prise avec
l'utilisateur de commencer par la logique PURE seulement, pas par une
couverture DOM via jsdom) : compareValues, resolveVariablePath,
evaluateConditionClause/Node, exactement la logique que les phases à
venir (opérations mathématiques, condition de collision) vont étendre.

tests/conftest.py : nouveau helper play_js_bundle() (concatène tout
static/js/play/*.js) — 13 tests existants qui vérifiaient la présence de
telle fonction/chaîne dans le HTML de /game/<slug>/play (tout le JS y
était inline avant ce découpage) sont mis à jour pour chercher dans ce
bundle à la place ; les tests qui vérifient un CSS/HTML réellement resté
dans play.html (forgeHighlight, forgeDisabled, #playFrame...) continuent
de chercher dans le HTML.

Vérifié : 215 tests pytest passent (aucune régression comportementale,
juste une réorganisation), 13 tests node:test passent, node --check sur
chacun des 6 nouveaux fichiers. Test manuel recommandé (jeu joué de bout
en bout : navigation, clic, survol, répéteur, condition, animation)
avant de considérer le découpage définitivement sans risque.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 13:59:11 +02:00

273 lines
15 KiB
Python

"""Tests du widget Jauge (2.1 : binding live d'une donnée vers un style)."""
import re
import db
from conftest import play_js_bundle
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 'value="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 'value="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 "--bulma-progress-value-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 'value="100.0"' in html
# ---------- Choix de l'enregistrement (plusieurs jauges dans un même objet)
# et champ affiché comme nom au-dessus de la barre ----------
def _create_jauge_object_with_rows(client, slug):
"""Un seul objet "jauge" avec trois enregistrements (name/value), comme
dans le scénario remonté : Réputation/Trésorerie/Confiance, toutes à 50
— le bug était de ne pas pouvoir distinguer laquelle une Jauge suit."""
resp = client.post(f"/game/{slug}/objects/new", data={
"object_name": "jauge",
"field_name[]": ["name", "value"], "field_type[]": ["texte", "nombre_entier"],
"field_relation[]": ["", ""], "field_required[]": ["0", "0"],
}, follow_redirects=False)
def_id = int(resp.headers["Location"].rstrip("/").split("/")[-1])
for name, value in [("Réputation", "50"), ("Trésorerie", "20"), ("Confiance", "80")]:
client.post(f"/game/{slug}/objects/{def_id}/data/new", data={"name": name, "value": value}, follow_redirects=False)
# La route de création redirige vers la liste (pas d'id de ligne dans le
# Location) — on relit les lignes enregistrées pour retrouver leurs id.
definition = db.get_definition(slug, def_id)
row_ids = {r["name"]: r["id"] for r in db.list_rows(slug, definition)}
return def_id, row_ids
def test_gauge_row_id_distinguishes_between_records_of_the_same_object(client, game):
"""Le bug remonté : trois Jauges reliées au même objet (via "value")
affichaient toutes la même valeur (celle de la ligne la plus récente),
faute de pouvoir choisir LAQUELLE suivre. row_id doit permettre de
cibler "Confiance" (80%) plutôt que "Trésorerie" (20%)."""
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100",
})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert 'value="80.0"' in html
def test_gauge_without_row_id_falls_back_to_latest_row_no_regression(client, game):
"""Sans row_id réglé (jauges créées avant ce champ, ou objet à une seule
ligne comme dans test_jauge.py historique) : comportement inchangé, la
ligne la plus récente."""
def_id, _row_ids = _create_jauge_object_with_rows(client, game)
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": "value", "ctrl_min": "0", "ctrl_max": "100",
})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert 'value="80.0"' in html # "Confiance", ajoutée en dernier
def test_gauge_name_field_renders_above_the_bar(client, game):
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
})
html = client.get(f"/game/{game}/play").data.decode()
assert ">Confiance<" in html
def test_gauge_without_champ_nom_shows_no_name_no_regression(client, game):
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_min": "0", "ctrl_max": "100",
})
html = client.get(f"/game/{game}/play").data.decode()
assert ">Confiance<" not in html
def test_gauge_bar_stays_visible_when_nested_in_a_container_with_a_name(client, game):
"""Régression : posée À L'INTÉRIEUR d'un conteneur (donc en hauteur
"auto", voir _style_string), la Jauge avec un nom affiché au-dessus ne
doit pas voir sa barre s'effondrer à 0px — "flex:1 1 auto" seul n'a
rien à répartir dans un conteneur flex sans hauteur définie, d'où le
"min-height" plancher sur le wrapper de la barre (voir render_jauge.py).
On vérifie ici que la classe "jaugeBar" reste dans un wrapper qui porte
bien un min-height non nul, pas seulement sa présence dans le HTML."""
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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))
resp = client.post(f"/game/{game}/elements/{container_id}/children/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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
})
html = client.get(f"/game/{game}/play").data.decode()
assert ">Confiance<" in html
idx = html.find("jaugeBar")
div_start = html.rfind('<div', 0, idx)
wrap_style = html[div_start:html.find('>', div_start)]
assert "min-height:14px" in wrap_style
def test_gauge_name_position_beside_uses_row_layout(client, game):
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
"ctrl_nom_position": "cote",
})
html = client.get(f"/game/{game}/play").data.decode()
idx = html.find(f'data-element-id="{jauge_id}"')
style = html[html.find('style="', idx):html.find('>', idx)]
assert "flex-direction:row" in style
def test_gauge_name_appearance_controls_render_on_the_label(client, game):
"""Police, taille, gras et italique du nom doivent se retrouver dans le
style inline du libellé — et la taille (contrôle slider sans unité
stockée, voir registry.py) ne doit pas finir dupliquée ("18pxpx")."""
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
"ctrl_nom_police": "Georgia, serif", "ctrl_nom_taille": "18",
"ctrl_nom_gras": "1", "ctrl_nom_italique": "1",
})
html = client.get(f"/game/{game}/play").data.decode()
idx = html.find(">Confiance<")
style = html[html.rfind('style="', 0, idx):idx]
assert "font-family:Georgia, serif" in style
assert "font-size:18px" in style
assert "18pxpx" not in style
assert "font-weight:700" in style
assert "font-style:italic" in style
def test_gauge_name_left_alignment_only_applies_when_above(client, game):
def_id, row_ids = _create_jauge_object_with_rows(client, game)
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_row_id": str(row_ids["Confiance"]),
"ctrl_champ": "value", "ctrl_champ_nom": "name", "ctrl_min": "0", "ctrl_max": "100",
"ctrl_nom_position": "dessus", "ctrl_nom_alignement": "gauche",
})
html = client.get(f"/game/{game}/play").data.decode()
idx = html.find(">Confiance<")
style = html[html.rfind('style="', 0, idx):idx]
assert "text-align:left" in style
# ---------- 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."""
js = play_js_bundle()
assert "clickBound" in js
assert "if (elDiv.dataset.clickBound) return;" in js