"""Tests du widget Jauge (2.1 : binding live d'une donnée vers un style).""" import re import db 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 # ---------- 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 "width: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 "width: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") wrap_style = html[html.rfind('style="', 0, html.rfind('', 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.""" resp = client.get(f"/game/{game}/play") html = resp.data.decode() assert "clickBound" in html assert "if (elDiv.dataset.clickBound) return;" in html