From e9a991ed13bd1c544e60849d48e7d741f0b70b49 Mon Sep 17 00:00:00 2001 From: william Date: Tue, 25 Aug 2026 15:26:45 +0200 Subject: [PATCH] =?UTF-8?q?Un=20exemplaire=20d'=C3=A9l=C3=A9ment=20de=20je?= =?UTF-8?q?u=20pos=C3=A9=20sur=20un=20=C3=A9cran=20reste=20maintenant=20li?= =?UTF-8?q?=C3=A9=20=C3=A0=20son=20mod=C3=A8le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- screens/__init__.py | 1 - .../instantiate_template_tree.py | 48 ----------- screens/elements/add_element.py | 37 ++++----- screens/rendering/render_element_html.py | 27 ++++++- tests/test_element_type_live_instances.py | 80 +++++++++++++++++++ tests/test_screens_and_elements.py | 4 +- 6 files changed, 124 insertions(+), 73 deletions(-) delete mode 100644 screens/element_types/instantiate_template_tree.py create mode 100644 tests/test_element_type_live_instances.py diff --git a/screens/__init__.py b/screens/__init__.py index e21479e0..88f2f55e 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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.element_type_usage_count import element_type_usage_count 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.add_action import add_action diff --git a/screens/element_types/instantiate_template_tree.py b/screens/element_types/instantiate_template_tree.py deleted file mode 100644 index f9d31382..00000000 --- a/screens/element_types/instantiate_template_tree.py +++ /dev/null @@ -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 diff --git a/screens/elements/add_element.py b/screens/elements/add_element.py index ef7b3b0f..111093ca 100644 --- a/screens/elements/add_element.py +++ b/screens/elements/add_element.py @@ -6,7 +6,6 @@ from ..widgets.registry import WIDGETS from ..widgets.widget_meta import widget_meta from ..widgets.default_style_for_widget import default_style_and_attributes 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): @@ -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 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 - (le cas normal désormais — voir create_element_type), tout son arbre est - COPIÉ en profondeur ici plutôt que de créer un seul élément plat : c'est - ce qui permet à un élément de jeu d'être un mini-conteneur réutilisable - avec plusieurs éléments imbriqués à l'intérieur, pas juste un widget - unique avec un contenu par défaut.""" + element_type_id : si fourni, un SEUL élément est créé ici (ligne plate, + comme n'importe quel widget), qui garde juste une référence vers cet + élément de jeu — son contenu (l'arbre de son écran-modèle) n'est PAS + copié en base : il est rechargé EN DIRECT à chaque affichage (voir + render_element_html.py), exactement comme un modèle de ligne de + 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: types = {t["id"]: t for t in list_element_types(slug)} et = types.get(int(element_type_id)) - if et and et.get("template_screen_id"): - created = instantiate_template_tree(slug, et["template_screen_id"], screen_id, parent_id) - 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" + if et: + widget_key = et.get("default_widget") or widget_key meta = widget_meta(widget_key) # 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 diff --git a/screens/rendering/render_element_html.py b/screens/rendering/render_element_html.py index 240df2df..909e205d 100644 --- a/screens/rendering/render_element_html.py +++ b/screens/rendering/render_element_html.py @@ -17,6 +17,7 @@ from .render_onglets import _render_onglets from .render_icone import _render_icone from .resolve_bound_row import _resolve_bound_row_ctx 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): @@ -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)}>") 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"): 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", "
") if content else "" return _mark(f"<{tag} {_attr_string(attrs, style)}>{safe_content}{child_html}") + + +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"] + ) diff --git a/tests/test_element_type_live_instances.py b/tests/test_element_type_live_instances.py new file mode 100644 index 00000000..35249f6c --- /dev/null +++ b/tests/test_element_type_live_instances.py @@ -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"]) diff --git a/tests/test_screens_and_elements.py b/tests/test_screens_and_elements.py index ccf5d142..e61e4816 100644 --- a/tests/test_screens_and_elements.py +++ b/tests/test_screens_and_elements.py @@ -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)) - # ---- 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") resp = client.post( f"/game/{slug}/screens/{real_screen_id}/elements/add",