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>
143 lines
6.7 KiB
Python
143 lines
6.7 KiB
Python
"""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
|
|
|
|
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 _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 play_js_bundle()
|