Dev #1

Merged
w-vandal merged 62 commits from dev into main 2026-08-26 04:56:44 +00:00
6 changed files with 124 additions and 73 deletions
Showing only changes of commit e9a991ed13 - Show all commits
-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.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
@@ -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.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
+26 -1
View File
@@ -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", "<br>") if content else ""
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))
# ---- 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",