Implémente toutes les options manquantes identifiées pour l'élément Image : dimensionnement/ratio/object-fit, filtres CSS, upload de fichier (en plus de l'URL), lien/plein écran au clic, chargement différé, légende, et tous les attributs de boîte partagés déjà créés pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/ position du bloc). Système de pages : un support peut désormais avoir 0 page (un nouveau support démarre vide), suppression de toutes les pages en un clic, et la pagination automatique insère intelligemment la nouvelle page juste après celle qui déborde plutôt qu'en toute fin de liste. Bugs réels trouvés et corrigés en cours de route : le style de bloc (dont align-self) ciblait l'élément interne au lieu de son enveloppe (légende/lien) ; une image à sa taille native pouvait déclencher une pagination infinie ; upload/mise à jour d'attribut ne déclenchaient jamais le contrôle de débordement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
810 lines
37 KiB
Python
810 lines
37 KiB
Python
"""CRUD des éléments d'un support de formation (document_engine/elements/)
|
|
— sans Flask, directement contre le support.db créé par db.create_support.
|
|
Couvre : CRUD complet, rangées (parent_id), réinsertion (move_document_element
|
|
vers une nouvelle rangée/position), suppression en cascade d'une rangée avec
|
|
enfants.
|
|
|
|
Chaque élément appartient désormais à une page (voir document_engine/
|
|
pages/) — _new_support renvoie (slug, page_id) de la première page créée
|
|
par défaut, réutilisée par tous les tests de ce fichier."""
|
|
|
|
from typing import Any
|
|
|
|
import db
|
|
import document_engine as doc_engine
|
|
from document_engine.rendering.box_style import default_border as doc_engine_box_default_border
|
|
|
|
|
|
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
|
|
# db.create_support ne crée plus aucune page par défaut (retour
|
|
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) —
|
|
# ce helper en ajoute une explicitement, puisque tous les tests de ce
|
|
# fichier portent sur des éléments qui doivent bien appartenir à une
|
|
# page existante.
|
|
slug = db.create_support(name, owner_folder="90")
|
|
tmp_support_slug_cleanup(slug)
|
|
page_id = doc_engine.add_document_page(slug)
|
|
return slug, page_id
|
|
|
|
|
|
def test_add_and_get_document_element_uses_default_attributes(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["kind"] == "titre"
|
|
assert element["page_id"] == page_id
|
|
assert element["parent_id"] is None
|
|
assert element["attributes"]["content"] == "Nouveau titre"
|
|
|
|
|
|
def test_titre_default_attributes_include_all_new_formatting_fields(tmp_support_slug_cleanup: Any) -> None:
|
|
"""Audit du 26/09/2026 (réglages manquants) — chaque nouveau champ
|
|
doit exister avec une valeur "neutre" par défaut (comportement
|
|
historique inchangé, voir _render_text)."""
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
attrs = element["attributes"]
|
|
assert attrs["strikethrough"] is False
|
|
assert attrs["font_family"] == ""
|
|
assert attrs["font_size"] == ""
|
|
assert attrs["line_height"] == ""
|
|
assert attrs["letter_spacing"] == ""
|
|
assert attrs["text_transform"] == "none"
|
|
assert attrs["text_shadow"] == ""
|
|
assert attrs["padding"] == ""
|
|
assert attrs["margin"] == ""
|
|
assert attrs["background_color"] == ""
|
|
assert attrs["border_radius"] == ""
|
|
assert attrs["align_self"] == "stretch"
|
|
assert attrs["height"] == ""
|
|
assert attrs["min_height"] == ""
|
|
assert attrs["max_height"] == ""
|
|
assert attrs["min_width"] == ""
|
|
assert attrs["box_shadow"] == ""
|
|
assert attrs["opacity"] == ""
|
|
assert attrs["border"] == doc_engine_box_default_border()
|
|
|
|
html = doc_engine.render_document([element])
|
|
assert html == (
|
|
f'<div class="docText" data-element-id="{element_id}" data-kind="titre" '
|
|
f'style="font-size:clamp(1.6rem,4vw,2rem); font-weight:800; line-height:1.15; '
|
|
f'font-style:normal; text-decoration:none; text-align:left; color:var(--forge-text);">'
|
|
f"Nouveau titre</div>"
|
|
)
|
|
|
|
|
|
def test_titre_strikethrough_combines_with_underline(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"content": "X", "underline": True, "strikethrough": True}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "text-decoration:underline line-through;" in html
|
|
|
|
|
|
def test_titre_font_size_and_line_height_override_the_style_preset(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"content": "X", "style": "titre1", "font_size": "40px", "line_height": "1"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "font-size:40px;" in html
|
|
assert "line-height:1;" in html
|
|
assert "clamp(1.6rem" not in html
|
|
|
|
|
|
def test_titre_font_family_letter_spacing_and_text_shadow_render_when_set(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"content": "X",
|
|
"font_family": "Georgia, serif",
|
|
"letter_spacing": "0.05em",
|
|
"text_shadow": "1px 1px 2px rgba(0,0,0,.3)",
|
|
},
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "font-family:Georgia, serif;" in html
|
|
assert "letter-spacing:0.05em;" in html
|
|
assert "text-shadow:1px 1px 2px rgba(0,0,0,.3);" in html
|
|
|
|
|
|
def test_titre_text_transform_uppercase_renders_and_none_does_not(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
html_default = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "text-transform" not in html_default
|
|
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"content": "X", "text_transform": "uppercase"})
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "text-transform:uppercase;" in html
|
|
|
|
|
|
def test_titre_box_style_attributes_render_via_shared_box_style(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
border = doc_engine_box_default_border()
|
|
border["bottom"] = {"style": "dashed", "width": "3px", "color": "#123456"}
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"content": "X",
|
|
"padding": "10px",
|
|
"margin": "5px",
|
|
"background_color": "#fafafa",
|
|
"border_radius": "4px",
|
|
"align_self": "center",
|
|
"border": border,
|
|
"height": "300px",
|
|
"box_shadow": "0 4px 12px rgba(0,0,0,.2)",
|
|
"opacity": "0.9",
|
|
},
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "padding:10px;" in html
|
|
assert "margin:5px;" in html
|
|
assert "background-color:#fafafa;" in html
|
|
assert "border-radius:4px;" in html
|
|
assert "align-self:center;" in html
|
|
assert "border-bottom:3px dashed #123456;" in html
|
|
assert "height:300px;" in html
|
|
assert "box-shadow:0 4px 12px rgba(0,0,0,.2);" in html
|
|
assert "opacity:0.9;" in html
|
|
|
|
|
|
def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, _page_id = _new_support(tmp_support_slug_cleanup)
|
|
assert doc_engine.get_document_element(slug, 999) is None
|
|
|
|
|
|
def test_list_document_elements_groups_row_children_together(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
|
|
top_titre_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
child_a_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id, parent_id=row_id)
|
|
child_b_id = doc_engine.add_document_element(slug, "image", page_id=page_id, parent_id=row_id)
|
|
|
|
elements = doc_engine.list_document_elements(slug, page_id)
|
|
ids_in_order = [el["id"] for el in elements]
|
|
|
|
# Les deux top-niveau (row_id, top_titre_id) groupés avant les deux
|
|
# enfants de la rangée (child_a_id, child_b_id) — tri (parent_id, order_index).
|
|
assert ids_in_order.index(row_id) < ids_in_order.index(child_a_id)
|
|
assert ids_in_order.index(top_titre_id) < ids_in_order.index(child_a_id)
|
|
assert ids_in_order.index(child_a_id) < ids_in_order.index(child_b_id)
|
|
|
|
|
|
def test_list_document_elements_only_returns_the_given_page(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page1_id = _new_support(tmp_support_slug_cleanup)
|
|
page2_id = doc_engine.add_document_page(slug)
|
|
el1_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id)
|
|
el2_id = doc_engine.add_document_element(slug, "titre", page_id=page2_id)
|
|
|
|
assert [el["id"] for el in doc_engine.list_document_elements(slug, page1_id)] == [el1_id]
|
|
assert [el["id"] for el in doc_engine.list_document_elements(slug, page2_id)] == [el2_id]
|
|
|
|
|
|
def test_update_document_element_attributes_replaces_whole_json(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"content": "Texte modifié", "style": "legende"})
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["attributes"] == {"content": "Texte modifié", "style": "legende"}
|
|
|
|
|
|
def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
first_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
second_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
|
|
third_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
|
|
doc_engine.move_document_element(slug, third_id, None, 0)
|
|
|
|
elements = doc_engine.list_document_elements(slug, page_id)
|
|
top_level = [el for el in elements if el["parent_id"] is None]
|
|
ordered_ids = [el["id"] for el in sorted(top_level, key=lambda el: el["order_index"])]
|
|
assert ordered_ids == [third_id, first_id, second_id]
|
|
|
|
|
|
def test_move_document_element_reparents_into_a_row(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
|
|
lone_titre_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
|
|
doc_engine.move_document_element(slug, lone_titre_id, row_id, 0)
|
|
|
|
moved = doc_engine.get_document_element(slug, lone_titre_id)
|
|
assert moved is not None
|
|
assert moved["parent_id"] == row_id
|
|
assert moved["order_index"] == 0
|
|
|
|
|
|
def test_delete_document_element_cascades_to_row_children(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
|
|
child_id = doc_engine.add_document_element(slug, "titre", page_id=page_id, parent_id=row_id)
|
|
|
|
doc_engine.delete_document_element(slug, row_id)
|
|
|
|
assert doc_engine.get_document_element(slug, row_id) is None
|
|
assert doc_engine.get_document_element(slug, child_id) is None
|
|
|
|
|
|
def test_delete_document_element_on_missing_id_is_a_noop(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, _page_id = _new_support(tmp_support_slug_cleanup)
|
|
doc_engine.delete_document_element(slug, 999)
|
|
|
|
|
|
def test_render_document_includes_row_children(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
row_id = doc_engine.add_document_element(slug, "row", page_id=page_id)
|
|
titre_id = doc_engine.add_document_element(slug, "titre", page_id=page_id, parent_id=row_id)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
|
|
assert 'data-kind="row"' in html
|
|
assert 'data-kind="titre"' in html
|
|
assert f'data-element-id="{titre_id}"' in html
|
|
|
|
|
|
def test_move_document_element_to_page_transfers_a_top_level_element(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page1_id = _new_support(tmp_support_slug_cleanup)
|
|
page2_id = doc_engine.add_document_page(slug)
|
|
element_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id)
|
|
|
|
doc_engine.move_document_element_to_page(slug, element_id, page2_id)
|
|
|
|
moved = doc_engine.get_document_element(slug, element_id)
|
|
assert moved is not None
|
|
assert moved["page_id"] == page2_id
|
|
assert moved["parent_id"] is None
|
|
assert [el["id"] for el in doc_engine.list_document_elements(slug, page1_id)] == []
|
|
assert [el["id"] for el in doc_engine.list_document_elements(slug, page2_id)] == [element_id]
|
|
|
|
|
|
def test_move_document_element_to_page_cascades_row_children(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page1_id = _new_support(tmp_support_slug_cleanup)
|
|
page2_id = doc_engine.add_document_page(slug)
|
|
row_id = doc_engine.add_document_element(slug, "row", page_id=page1_id)
|
|
child_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id, parent_id=row_id)
|
|
|
|
doc_engine.move_document_element_to_page(slug, row_id, page2_id)
|
|
|
|
child = doc_engine.get_document_element(slug, child_id)
|
|
assert child is not None
|
|
assert child["page_id"] == page2_id
|
|
assert child["parent_id"] == row_id
|
|
page2_ids = {el["id"] for el in doc_engine.list_document_elements(slug, page2_id)}
|
|
assert page2_ids == {row_id, child_id}
|
|
|
|
|
|
def test_move_document_element_to_page_renumbers_remaining_siblings(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page1_id = _new_support(tmp_support_slug_cleanup)
|
|
page2_id = doc_engine.add_document_page(slug)
|
|
first_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id)
|
|
second_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page1_id)
|
|
third_id = doc_engine.add_document_element(slug, "image", page_id=page1_id)
|
|
|
|
doc_engine.move_document_element_to_page(slug, second_id, page2_id)
|
|
|
|
remaining = doc_engine.list_document_elements(slug, page1_id)
|
|
remaining_ordered = sorted(remaining, key=lambda el: el["order_index"])
|
|
assert [el["id"] for el in remaining_ordered] == [first_id, third_id]
|
|
assert [el["order_index"] for el in remaining_ordered] == [0, 1]
|
|
|
|
|
|
def test_move_document_element_to_page_on_missing_id_is_a_noop(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page1_id = _new_support(tmp_support_slug_cleanup)
|
|
page2_id = doc_engine.add_document_page(slug)
|
|
doc_engine.move_document_element_to_page(slug, 999, page2_id)
|
|
assert doc_engine.list_document_elements(slug, page1_id) == []
|
|
assert doc_engine.list_document_elements(slug, page2_id) == []
|
|
|
|
|
|
def test_liste_puces_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["attributes"]["items"] == ["Premier élément", "Deuxième élément"]
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert html.startswith("<ul")
|
|
assert 'data-kind="liste_puces"' in html
|
|
assert "<li>Premier élément</li>" in html
|
|
assert "<li>Deuxième élément</li>" in html
|
|
|
|
|
|
def test_liste_numerotee_renders_an_ordered_list(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "liste_numerotee", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["Un", "Deux", "Trois"]})
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert html.startswith("<ol")
|
|
assert 'data-kind="liste_numerotee"' in html
|
|
assert "<li>Un</li><li>Deux</li><li>Trois</li>" in html
|
|
|
|
|
|
def test_liste_puces_escapes_item_content(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["<script>alert(1)</script>"]})
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "<script>" not in html
|
|
assert "<script>" in html
|
|
|
|
|
|
def test_liste_puces_with_no_items_renders_an_empty_list(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
|
|
element_id = doc_engine.list_document_elements(slug, page_id)[0]["id"]
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"items": []})
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert html == f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces"></ul>'
|
|
|
|
|
|
def test_badge_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["attributes"] == {
|
|
"content": "Étiquette",
|
|
"svg_markup": "",
|
|
"width": "",
|
|
"border_radius": "",
|
|
"bold": False,
|
|
"uppercase": False,
|
|
}
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert html == f'<div class="docBadge" data-element-id="{element_id}" data-kind="badge">Étiquette</div>'
|
|
|
|
|
|
def test_badge_escapes_content(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"content": "<script>alert(1)</script>"})
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "<script>" not in html
|
|
assert "<script>" in html
|
|
|
|
|
|
def test_badge_with_svg_markup_renders_a_sanitized_icon(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"content": "Module obligatoire",
|
|
"svg_markup": '<svg onload="alert(1)"><circle cx="1" cy="1" r="1"/><script>alert(2)</script></svg>',
|
|
},
|
|
)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert '<span class="docBadgeIcon">' in html
|
|
assert "<circle" in html
|
|
assert "onload" not in html
|
|
assert "<script>" not in html
|
|
|
|
|
|
def test_badge_without_svg_markup_renders_no_icon_span(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "docBadgeIcon" not in html
|
|
|
|
|
|
def test_badge_with_width_also_sets_align_self(tmp_support_slug_cleanup: Any) -> None:
|
|
"""Fixer une largeur doit TOUJOURS sortir du comportement par défaut
|
|
(pleine largeur, étirement flex) — les deux vont toujours ensemble,
|
|
voir render_document_element._render_badge."""
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"content": "X", "width": "fit-content"})
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "align-self:flex-start;" in html
|
|
assert "width:fit-content;" in html
|
|
|
|
|
|
def test_badge_border_radius_bold_and_uppercase_render_inline_style(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"content": "X", "border_radius": "999px", "bold": True, "uppercase": True}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "border-radius:999px;" in html
|
|
assert "font-weight:800;" in html
|
|
assert "text-transform:uppercase;" in html
|
|
|
|
|
|
def test_carte_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["attributes"] == {
|
|
"label": "A",
|
|
"title": "Titre de la carte",
|
|
"description": "Description de la carte.",
|
|
}
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert html == (
|
|
f'<div class="docCard" data-element-id="{element_id}" data-kind="carte">'
|
|
f'<div class="docCardLabel">A</div>'
|
|
f'<div class="docCardTitle">Titre de la carte</div>'
|
|
f'<div class="docCardDescription">Description de la carte.</div>'
|
|
f"</div>"
|
|
)
|
|
|
|
|
|
def test_carte_escapes_all_fields(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "carte", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"label": "<b>", "title": "<i>", "description": "<script>alert(1)</script>"}
|
|
)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "<script>" not in html
|
|
assert "<b>" not in html
|
|
assert "<i>" not in html
|
|
|
|
|
|
def test_image_default_attributes_include_empty_svg_markup(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
attrs = element["attributes"]
|
|
assert attrs["src"] == ""
|
|
assert attrs["alt"] == ""
|
|
assert attrs["svg_markup"] == ""
|
|
# Cadre FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre
|
|
# ne devrait pas changer de taille en fonction de la taille de
|
|
# l'image mais être fixe et contraindre l'image dedans") — une photo
|
|
# importée est TOUJOURS rognée pour remplir ce cadre par défaut ;
|
|
# "Taille réelle" (object_fit="") reste un choix explicite possible
|
|
# via le panneau Propriétés, jamais le défaut d'un nouvel élément.
|
|
assert attrs["object_fit"] == "cover"
|
|
assert attrs["height"] == "220px"
|
|
assert attrs["aspect_ratio"] == ""
|
|
assert attrs["filter_preset"] == ""
|
|
assert attrs["click_behavior"] == ""
|
|
assert attrs["link_url"] == ""
|
|
assert attrs["lazy_load"] is False
|
|
assert attrs["caption"] == ""
|
|
assert attrs["border"] == doc_engine_box_default_border()
|
|
|
|
|
|
def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{"src": "https://exemple.test/photo.png", "alt": "", "svg_markup": '<svg><circle cx="5" cy="5" r="3"/></svg>'},
|
|
)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "<img" not in html
|
|
assert f'<div class="docImage" data-element-id="{element_id}" data-kind="image">' in html
|
|
assert '<circle cx="5" cy="5" r="3">' in html or '<circle cx="5" cy="5" r="3"/>' in html
|
|
|
|
|
|
def test_image_svg_markup_strips_script_tag(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
malicious = '<svg onload="alert(1)"><script>alert(2)</script><circle cx="1" cy="1" r="1" onclick="alert(3)"/></svg>'
|
|
doc_engine.update_document_element_attributes(slug, element_id, {"src": "", "alt": "", "svg_markup": malicious})
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "<script>" not in html
|
|
assert "onload" not in html
|
|
assert "onclick" not in html
|
|
assert "alert(" not in html
|
|
|
|
|
|
def test_image_object_fit_renders_only_known_values(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "cover"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "object-fit:cover;" in html
|
|
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "n-importe-quoi"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "object-fit" not in html
|
|
|
|
|
|
def test_image_aspect_ratio_renders_and_is_escaped(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "aspect_ratio": "16 / 9"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "aspect-ratio:16 / 9;" in html
|
|
|
|
|
|
def test_image_filter_preset_maps_to_a_real_css_filter(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "filter_preset": "grayscale"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "filter:grayscale(1);" in html
|
|
|
|
|
|
def test_image_lazy_load_adds_the_loading_attribute(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "lazy_load": True}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert 'loading="lazy"' in html
|
|
|
|
|
|
def test_image_caption_wraps_in_a_figure(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "caption": "Une légende <script>x</script>"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert '<figure class="docImageFigure">' in html
|
|
assert '<figcaption class="docImageCaption">' in html
|
|
assert "<script>" not in html
|
|
assert "<script>" in html
|
|
|
|
|
|
def test_image_click_behavior_link_wraps_in_an_anchor(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"src": "https://exemple.test/a.png",
|
|
"alt": "",
|
|
"click_behavior": "link",
|
|
"link_url": "https://exemple.test/cible",
|
|
},
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert (
|
|
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer">' in html
|
|
)
|
|
|
|
|
|
def test_image_click_behavior_link_without_url_does_not_wrap(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "link", "link_url": ""}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "docImageLink" not in html
|
|
|
|
|
|
def test_image_click_behavior_lightbox_wraps_in_a_trigger_div(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "lightbox"}
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert '<div class="docImageLightboxTrigger">' in html
|
|
|
|
|
|
def test_image_box_style_attributes_render_and_placeholder_gets_them_too(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
border = doc_engine_box_default_border()
|
|
border["left"] = {"style": "dotted", "width": "2px", "color": "#00ff00"}
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"src": "",
|
|
"alt": "",
|
|
"padding": "8px",
|
|
"border_radius": "20px",
|
|
"width": "300px",
|
|
"border": border,
|
|
},
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "docImagePlaceholder" in html
|
|
assert "padding:8px;" in html
|
|
assert "border-radius:20px;" in html
|
|
assert "width:300px;" in html
|
|
assert "border-left:2px dotted #00ff00;" in html
|
|
|
|
|
|
def test_image_box_style_targets_the_figure_when_a_caption_is_present(tmp_support_slug_cleanup: Any) -> None:
|
|
# Bug réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
|
|
# pas sur l'image" — align-self (et le reste de render_box_style) doit
|
|
# se poser sur l'élément RÉELLEMENT top-niveau (l'enfant direct du
|
|
# flex-column de la page), jamais sur l'<img> interne quand une
|
|
# légende l'enveloppe : sans quoi align-self n'a aucun effet visuel.
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"src": "https://exemple.test/a.png",
|
|
"alt": "",
|
|
"caption": "Légende",
|
|
"align_self": "center",
|
|
"width": "300px",
|
|
},
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert '<figure class="docImageFigure" style="width:300px; align-self:center;">' in html
|
|
# align-self ne doit apparaître qu'UNE fois (sur la figure), jamais
|
|
# aussi sur l'<img> interne (ce qui n'aurait aucun effet CSS).
|
|
assert html.count("align-self") == 1
|
|
|
|
|
|
def test_image_box_style_targets_the_link_wrapper_when_no_caption(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"src": "https://exemple.test/a.png",
|
|
"alt": "",
|
|
"click_behavior": "link",
|
|
"link_url": "https://exemple.test/cible",
|
|
"align_self": "center",
|
|
},
|
|
)
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert (
|
|
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer" '
|
|
'style="align-self:center;">' in html
|
|
)
|
|
|
|
|
|
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["attributes"] == {
|
|
"label": "Bouton",
|
|
"target": "",
|
|
"attachment_stored_name": "",
|
|
"attachment_filename": "",
|
|
}
|
|
|
|
|
|
def test_replace_document_content_rebuilds_pages_and_elements_from_seed(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
doc_engine.add_document_element(slug, "titre", page_id=page_id)
|
|
|
|
seed_pages: list[dict[str, Any]] = [
|
|
{
|
|
"vertical_align": "center",
|
|
"blocks": [
|
|
{"kind": "titre", "attributes": {"content": "Page 1 du modèle"}},
|
|
{
|
|
"kind": "row",
|
|
"attributes": {"gap": 20},
|
|
"children": [
|
|
{"kind": "paragraphe", "attributes": {"content": "Colonne gauche"}},
|
|
{"kind": "paragraphe", "attributes": {"content": "Colonne droite"}},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
{"blocks": [{"kind": "liste_puces", "attributes": {"items": ["Un", "Deux"]}}]},
|
|
]
|
|
doc_engine.replace_document_content(slug, seed_pages)
|
|
|
|
pages = doc_engine.list_document_pages(slug)
|
|
assert len(pages) == 2
|
|
assert pages[0]["vertical_align"] == "center"
|
|
assert pages[1]["vertical_align"] == "top"
|
|
|
|
page1_elements = doc_engine.list_document_elements(slug, pages[0]["id"])
|
|
titre = next(el for el in page1_elements if el["kind"] == "titre")
|
|
assert titre["attributes"]["content"] == "Page 1 du modèle"
|
|
row = next(el for el in page1_elements if el["kind"] == "row")
|
|
children = [el for el in page1_elements if el["parent_id"] == row["id"]]
|
|
assert len(children) == 2
|
|
assert {c["attributes"]["content"] for c in children} == {"Colonne gauche", "Colonne droite"}
|
|
|
|
page2_elements = doc_engine.list_document_elements(slug, pages[1]["id"])
|
|
assert page2_elements[0]["kind"] == "liste_puces"
|
|
assert page2_elements[0]["attributes"]["items"] == ["Un", "Deux"]
|
|
|
|
|
|
def test_replace_document_content_merges_attributes_onto_defaults(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, _page_id = _new_support(tmp_support_slug_cleanup)
|
|
doc_engine.replace_document_content(
|
|
slug, [{"blocks": [{"kind": "titre", "attributes": {"content": "Seulement le contenu"}}]}]
|
|
)
|
|
page_id = doc_engine.list_document_pages(slug)[0]["id"]
|
|
element = doc_engine.list_document_elements(slug, page_id)[0]
|
|
assert element["attributes"]["content"] == "Seulement le contenu"
|
|
assert element["attributes"]["style"] == "titre1"
|
|
assert element["attributes"]["align"] == "left"
|
|
|
|
|
|
def test_bouton_with_attachment_filename_renders_data_attribute(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug,
|
|
element_id,
|
|
{
|
|
"label": "Télécharger",
|
|
"target": "",
|
|
"attachment_stored_name": "abc123.pdf",
|
|
"attachment_filename": "fiche-consignes.pdf",
|
|
},
|
|
)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert 'data-attachment-filename="fiche-consignes.pdf"' in html
|
|
|
|
|
|
def test_paragraphe_default_max_width_is_empty(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
|
|
element = doc_engine.get_document_element(slug, element_id)
|
|
assert element is not None
|
|
assert element["attributes"]["max_width"] == ""
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "max-width" not in html
|
|
|
|
|
|
def test_paragraphe_with_max_width_adds_it_to_the_inline_style(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"content": "Texte", "style": "paragraphe", "max_width": "60ch"}
|
|
)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "max-width:60ch;" in html
|
|
|
|
|
|
def test_paragraphe_max_width_is_escaped(tmp_support_slug_cleanup: Any) -> None:
|
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
|
element_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page_id)
|
|
doc_engine.update_document_element_attributes(
|
|
slug, element_id, {"content": "Texte", "style": "paragraphe", "max_width": '"><script>alert(1)</script>'}
|
|
)
|
|
|
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
|
assert "<script>" not in html
|