Un exemplaire d'élément de jeu posé sur un écran reste maintenant lié à son modèle

Jusqu'ici, poser un élément de jeu depuis le catalogue ("Mes éléments de
jeu") copiait tout son arbre en base (instantiate_template_tree) : chaque
exemplaire devenait indépendant, y compris de son propre modèle - modifier
l'élément de jeu dans son éditeur n'avait plus aucun effet sur les
exemplaires déjà posés ailleurs.

Change ce comportement pour qu'un exemplaire reste TOUJOURS lié à son
modèle, sur le même principe déjà utilisé par un modèle de ligne de
Répéteur (jamais copié, rechargé en direct à chaque affichage - voir
_load_template_tree/_render_repeater) : add_element.py ne crée plus
qu'UNE SEULE ligne plate (avec sa position/taille propres à cet
exemplaire) au lieu de copier tout l'arbre, et render_element_html.py
recharge le contenu depuis l'écran-modèle à chaque rendu quand
element_type_id est réglé. Modifier l'élément de jeu dans son propre
éditeur met donc à jour tous ses exemplaires déjà posés, sur n'importe
quel écran (y compris ceux placés AVANT ce correctif, qui portaient déjà
element_type_id sur leur ligne de premier niveau), sans avoir à les
retoucher un par un.

Contrepartie assumée (discutée avec l'utilisateur avant ce changement) :
un exemplaire ne peut plus être personnalisé individuellement à
l'INTÉRIEUR (texte, couleur d'un enfant précis...) - seules sa position et
sa taille sur l'écran restent propres à chaque exemplaire. Pour changer le
contenu, il faut désormais passer par l'éditeur de l'élément de jeu
lui-même.

instantiate_template_tree.py, devenu inutilisé, est supprimé.

Ajoute tests/test_element_type_live_instances.py (mise à jour d'un
exemplaire déjà posé, propagation jusqu'à "Jouer", position toujours
indépendante par exemplaire) et met à jour un commentaire de test devenu
obsolète dans test_screens_and_elements.py.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-25 15:26:45 +02:00
co-authored by Claude Sonnet 5
parent 79fe04c6ac
commit e9a991ed13
6 changed files with 124 additions and 73 deletions
-1
View File
@@ -90,7 +90,6 @@ from .element_types.create_element_type import create_element_type
from .element_types.update_element_type import update_element_type from .element_types.update_element_type import update_element_type
from .element_types.element_type_usage_count import element_type_usage_count from .element_types.element_type_usage_count import element_type_usage_count
from .element_types.delete_element_type import delete_element_type from .element_types.delete_element_type import delete_element_type
from .element_types.instantiate_template_tree import instantiate_template_tree
from .legacy_actions.list_actions import list_actions from .legacy_actions.list_actions import list_actions
from .legacy_actions.add_action import add_action from .legacy_actions.add_action import add_action
@@ -1,48 +0,0 @@
import db
def instantiate_template_tree(slug, template_screen_id, target_screen_id, target_parent_id=None):
"""Copie EN PROFONDEUR le contenu d'un écran-modèle vers un écran réel
(ou à l'intérieur d'un conteneur d'un écran réel) : chaque élément copié
devient une vraie ligne indépendante en base, modifiable séparément par
la suite — exactement le comportement d'un élément de jeu "à l'ancienne"
posé sur un écran. Renvoie la liste des ids créés au premier niveau."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT * FROM _screen_elements WHERE screen_id = ? ORDER BY id", (template_screen_id,)
).fetchall()
conn.close()
source = [dict(r) for r in rows]
created_top_ids = []
def copy_one(src, new_parent_id):
conn = db.connect(slug)
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (target_screen_id,)
).fetchone()["m"]
conn.execute(
"""INSERT INTO _screen_elements
(screen_id, parent_id, widget, name, content, attributes, x, y, width, height, z_index, style)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
target_screen_id, new_parent_id, src["widget"], src.get("name"),
src["content"], src["attributes"], src["x"], src["y"], src["width"], src["height"],
(max_z or 0) + 1, src["style"],
),
)
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
conn.commit()
conn.close()
return new_id
def walk(src_parent_id, new_parent_id):
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
new_id = copy_one(child, new_parent_id)
walk(child["id"], new_id)
for t in [s for s in source if not s.get("parent_id")]:
new_id = copy_one(t, target_parent_id)
created_top_ids.append(new_id)
walk(t["id"], new_id)
return created_top_ids
+15 -22
View File
@@ -6,7 +6,6 @@ from ..widgets.registry import WIDGETS
from ..widgets.widget_meta import widget_meta from ..widgets.widget_meta import widget_meta
from ..widgets.default_style_for_widget import default_style_and_attributes from ..widgets.default_style_for_widget import default_style_and_attributes
from ..element_types.list_element_types import list_element_types from ..element_types.list_element_types import list_element_types
from ..element_types.instantiate_template_tree import instantiate_template_tree
def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None): def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=None):
@@ -16,30 +15,24 @@ def add_element(slug, screen_id, widget_key, element_type_id=None, parent_id=Non
alors plus (il suit le flux du parent), mais on garde des valeurs par alors plus (il suit le flux du parent), mais on garde des valeurs par
défaut pour rester cohérent si jamais il en sort un jour. défaut pour rester cohérent si jamais il en sort un jour.
element_type_id : si fourni ET que cet élément de jeu a un écran-modèle element_type_id : si fourni, un SEUL élément est créé ici (ligne plate,
(le cas normal désormais — voir create_element_type), tout son arbre est comme n'importe quel widget), qui garde juste une référence vers cet
COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS
ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable copié en base : il est rechargé EN DIRECT à chaque affichage (voir
avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget render_element_html.py), exactement comme un modèle de ligne de
unique avec un contenu par défaut.""" Répéteur (voir _load_template_tree/_render_repeater). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un — au prix de ne plus pouvoir personnaliser
individuellement le CONTENU d'un exemplaire précis (sa position/taille
sur l'écran, elles, restent propres à chaque exemplaire, comme
d'habitude)."""
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
if element_type_id: if element_type_id:
types = {t["id"]: t for t in list_element_types(slug)} types = {t["id"]: t for t in list_element_types(slug)}
et = types.get(int(element_type_id)) et = types.get(int(element_type_id))
if et and et.get("template_screen_id"): if et:
created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id) widget_key = et.get("default_widget") or widget_key
if created:
conn = db.connect(slug)
conn.execute(
"UPDATE _screen_elements SET element_type_id = ? WHERE id = ?", (et["id"], created[0])
)
conn.commit()
conn.close()
return created[0]
# Modèle vide (aucun élément dedans pour l'instant) : on retombe
# sur l'ancien comportement plat ci-dessous, pour ne jamais
# échouer silencieusement à poser quelque chose.
widget_key = widget_key if widget_key in WIDGETS else "conteneur"
meta = widget_meta(widget_key) meta = widget_meta(widget_key)
# Réglages dont la valeur par défaut a un effet visuel voulu dès la # Réglages dont la valeur par défaut a un effet visuel voulu dès la
# création (ex: une image démarre avec "cover" pour se recadrer # création (ex: une image démarre avec "cover" pour se recadrer
+26 -1
View File
@@ -17,6 +17,7 @@ from .render_onglets import _render_onglets
from .render_icone import _render_icone from .render_icone import _render_icone
from .resolve_bound_row import _resolve_bound_row_ctx from .resolve_bound_row import _resolve_bound_row_ctx
from .visibility_condition import is_element_visible from .visibility_condition import is_element_visible
from ..element_types.load_template_tree import _load_template_tree
def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None): def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_direction=None):
@@ -106,7 +107,9 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
return _mark(f"<{tag} {_attr_string(attrs, style)}>") return _mark(f"<{tag} {_attr_string(attrs, style)}>")
content = _apply_ctx(el.get("content") or "", ctx) content = _apply_ctx(el.get("content") or "", ctx)
child_html = _render_children(el, slug, children_map, ctx) child_html = _render_element_type_children(el, slug, ctx) if el.get("element_type_id") else None
if child_html is None:
child_html = _render_children(el, slug, children_map, ctx)
if tag in ("ul", "ol"): if tag in ("ul", "ol"):
items = [line.strip() for line in content.split("\n") if line.strip()] items = [line.strip() for line in content.split("\n") if line.strip()]
@@ -115,3 +118,25 @@ def render_element_html(el, slug=None, children_map=None, ctx=None, parent_flex_
safe_content = html_lib.escape(content).replace("\n", "<br>") if content else "" safe_content = html_lib.escape(content).replace("\n", "<br>") if content else ""
return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>") return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}</{tag}>")
def _render_element_type_children(el, slug, ctx):
"""Contenu d'un exemplaire d'élément de jeu posé sur un écran (voir
add_element.py, colonne element_type_id) : rechargé EN DIRECT depuis
l'écran-modèle à chaque affichage, jamais depuis d'éventuels enfants
copiés en base — exactement le même principe qu'un modèle de ligne de
Répéteur (voir _render_repeater/_load_template_tree). Modifier
l'élément de jeu dans son propre éditeur met donc à jour tous ses
exemplaires déjà posés, sur n'importe quel écran, sans avoir à les
retoucher un par un. Renvoie None (pas "") si l'élément de jeu n'a
plus de modèle valide, pour que l'appelant retombe sur le rendu
générique (d'éventuels enfants en base issus d'une version antérieure
de ce mécanisme) plutôt que d'afficher un exemplaire silencieusement
vide."""
template_tree = _load_template_tree(slug, el["element_type_id"])
if not template_tree:
return None
return "".join(
render_element_html(c, slug, template_tree["children_map"], ctx)
for c in template_tree["top"]
)
+80
View File
@@ -0,0 +1,80 @@
"""Un exemplaire d'élément de jeu posé directement sur un écran (catalogue)
est désormais TOUJOURS lié à son modèle, comme un modèle de ligne de
Répéteur (voir add_element.py, render_element_html.py) : modifier
l'élément de jeu dans son propre éditeur met à jour tous ses exemplaires
déjà posés, sans avoir à les retoucher un par un — au prix de ne plus
pouvoir personnaliser individuellement le contenu d'un exemplaire précis."""
import re
import screens
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_card_element_type(client, slug, name="Carte"):
resp = client.post(f"/game/{slug}/element-types", data={"name": name, "icon": "🃏"}, follow_redirects=False)
assert resp.status_code == 302
et = next(t for t in screens.list_element_types(slug) if t["name"] == name)
resp = client.post(f"/game/{slug}/screens/{et['template_screen_id']}/elements/add", data={"widget": "titre"}, follow_redirects=False)
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": "Version 1"})
return et, titre_id
def test_editing_the_template_updates_an_already_placed_instance(client, game):
et, titre_id = _create_card_element_type(client, game)
screen_id = _create_screen(client, game)
client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
follow_redirects=False,
)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "Version 1" in html
client.post(f"/game/{game}/elements/{titre_id}/save", data={"ctrl_content": "Version 2"})
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "Version 2" in html
assert "Version 1" not in html
def test_editing_the_template_updates_the_play_page_too(client, game):
et, titre_id = _create_card_element_type(client, game)
screen_id = _create_screen(client, game)
client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
follow_redirects=False,
)
client.post(f"/game/{game}/elements/{titre_id}/save", data={"ctrl_content": "Contenu mis à jour"})
html = client.get(f"/game/{game}/play").data.decode()
assert "Contenu mis à jour" in html
def test_instance_position_on_screen_is_independent_per_instance(client, game):
"""Non-régression : la position/taille sur l'écran reste propre à
chaque exemplaire (seul le CONTENU intérieur est désormais partagé)."""
et, _titre_id = _create_card_element_type(client, game)
screen_id = _create_screen(client, game)
resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
follow_redirects=False,
)
first_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
resp = client.post(
f"/game/{game}/screens/{screen_id}/elements/add",
data={"widget": "__catalogue__", "element_type_id": str(et["id"])},
follow_redirects=False,
)
second_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
first = screens.get_element(game, first_id)
second = screens.get_element(game, second_id)
assert (first["x"], first["y"]) != (second["x"], second["y"])
+3 -1
View File
@@ -120,7 +120,9 @@ def test_element_type_template_case_a_and_case_b(client, game, mail_object):
element_type_id = int(re.search(r"element-types/(\d+)/delete", html).group(1)) 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 ---- # ---- Case A : posé directement sur un écran réel -> lié en direct au
# modèle (voir add_element.py/render_element_html.py), mais {{sujet}}
# reste non résolu hors Répéteur (pas de ligne de données courante) ----
real_screen_id = _create_screen(client, slug, "Accueil") real_screen_id = _create_screen(client, slug, "Accueil")
resp = client.post( resp = client.post(
f"/game/{slug}/screens/{real_screen_id}/elements/add", f"/game/{slug}/screens/{real_screen_id}/elements/add",