Files
Forge-Engine/tests/document/test_document_elements.py
T
williamandClaude Sonnet 5 4db1269348 Audit complet de mise en forme — Liste à puces/numérotée (4e élément)
Implémente toutes les options manquantes identifiées pour les listes :
typographie complète, style/position/couleur/taille de puce (validés
selon le kind), puce personnalisée en SVG pour les listes à puces,
padding uniforme par élément (nouveau, il n'y en avait aucun), espacement
entre éléments réglable, et tous les attributs de boîte partagés sur la
liste entière. Bordure/fond/padding par élément individuel et sous-listes
imbriquées volontairement différés (portée actée avec l'utilisateur
avant implémentation : transformeraient le stockage des éléments en
objets structurés, chantier bien plus lourd).

Trois ajouts transversaux bénéficiant à plusieurs éléments : sections
"Contenu"/"Conteneur" dans tous les panneaux de propriétés, alignement
vertical du contenu dans son bloc (Titre/Paragraphe/Liste/Image
légendée), et une option pour retirer un thème appliqué ("Aucun modèle"
dans la modale, avec une nouvelle fonction db.remove_document_theme).

Quatre bugs réels trouvés et corrigés en chaîne pendant la validation
avec le thème "Sécurité incendie" : un badge de thème s'affichait
au-dessus du texte au lieu d'à côté ; le correctif a d'abord fait
disparaître les puces/numéros natifs de TOUTES les listes (bug plus
grave que celui corrigé) ; puis un marqueur natif redondant apparaissait
à côté du badge du thème ; puis une règle CSS site-large de spécificité
supérieure empêchait silencieusement ce dernier correctif. Chaque étape
vérifiée par navigateur automatisé sur un support jetable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 15:13:14 +02:00

1168 lines
55 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_titre_content_align_renders_justify_content_when_set(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur".
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", "height": "300px", "content_align": "center"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "justify-content:center;" 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 "&lt;script&gt;" 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_liste_default_attributes_include_all_new_formatting_fields(tmp_support_slug_cleanup: Any) -> None:
"""Audit du 26/09/2026 (réglages manquants — colonne "Liste à puces/
numérotée") : chaque nouveau champ doit exister avec une valeur
"neutre" par défaut, sauf item_padding (retour utilisateur explicite :
"il faut un padding de base par élément de liste car y en a pas
aujourd'hui" — comportement historique inchangé sinon, voir
_render_list)."""
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
attrs = element["attributes"]
assert attrs["bold"] is False
assert attrs["italic"] is False
assert attrs["underline"] is False
assert attrs["font_family"] == ""
assert attrs["font_size"] == ""
assert attrs["line_height"] == ""
assert attrs["text_color"] == ""
assert attrs["list_style_type"] == ""
assert attrs["list_style_position"] == "outside"
assert attrs["marker_color"] == ""
assert attrs["marker_size"] == ""
assert attrs["svg_markup"] == ""
assert attrs["item_padding"] == "6px"
assert attrs["item_spacing"] == ""
assert attrs["border"] == doc_engine_box_default_border()
html = doc_engine.render_document([element])
assert html == (
f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces" '
f'style="--doc-item-padding:6px;">'
f"<li>Premier élément</li><li>Deuxième élément</li></ul>"
)
def test_liste_typography_attributes_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, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"items": ["X"],
"bold": True,
"italic": True,
"underline": True,
"font_family": "Georgia, serif",
"font_size": "18px",
"line_height": "1.8",
"text_color": "#ff0000",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "font-weight:700;" in html
assert "font-style:italic;" in html
assert "text-decoration:underline;" in html
assert "font-family:Georgia, serif;" in html
assert "font-size:18px;" in html
assert "line-height:1.8;" in html
assert "color:#ff0000;" in html
def test_liste_puces_marker_style_type_is_validated_per_kind(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": ["X"], "list_style_type": "square"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type:square;" in html
# Une valeur propre aux listes NUMÉROTÉES est ignorée pour une liste à puces.
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "decimal"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type" not in html
def test_liste_numerotee_marker_style_type_is_validated_per_kind(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": ["X"], "list_style_type": "upper-roman"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type:upper-roman;" in html
# Une valeur propre aux listes à puces est ignorée pour une liste numérotée.
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "square"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type" not in html
def test_liste_style_position_inside_renders(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": ["X"], "list_style_position": "inside"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-position:inside;" in html
def test_liste_removes_reserved_indent_when_no_visible_outside_bullet(tmp_support_slug_cleanup: Any) -> None:
# Bug réel constaté le 26/09/2026 : "si j'enlève les puces ou que les
# puces se mettent à l'intérieur, il reste un espace devant la
# liste, cet espace doit être supprimé" — le padding-left:1.4em par
# défaut (document-editor.css, .docList) réserve la place d'une
# puce EXTÉRIEURE, qui n'a plus lieu d'être dans ces deux cas.
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": ["X"], "list_style_type": "none"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding-left:0;" in html
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_position": "inside"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding-left:0;" in html
# Ni l'un ni l'autre réglé : la puce par défaut (disc) reste
# extérieure, la réservation d'espace reste nécessaire — jamais
# supprimée sans raison.
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"]})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding-left:0;" not in html
def test_liste_marker_color_and_size_render_as_custom_properties(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": ["X"], "marker_color": "#00ff00", "marker_size": "20px"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "--doc-marker-color:#00ff00;" in html
assert "--doc-marker-size:20px;" in html
def test_liste_puces_custom_bullet_image_renders_as_data_uri(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": ["X"], "svg_markup": "<svg><circle cx='1' cy='1' r='1'/></svg>"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'list-style-image:url("data:image/svg+xml,' in html
def test_liste_numerotee_ignores_custom_bullet_image(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": ["X"], "svg_markup": "<svg><circle cx='1' cy='1' r='1'/></svg>"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-image" not in html
def test_liste_item_padding_and_spacing_render_as_custom_properties(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": ["X"], "item_padding": "10px", "item_spacing": "12px"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "--doc-item-padding:10px;" in html
assert "--doc-item-spacing:12px;" in html
def test_liste_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, "liste_puces", 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,
{
"items": ["X"],
"padding": "12px",
"margin": "5px",
"background_color": "#fafafa",
"border_radius": "4px",
"align_self": "center",
"border": border,
"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:12px;" 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 "box-shadow:0 4px 12px rgba(0,0,0,.2);" in html
assert "opacity:0.9;" in html
def test_liste_content_align_renders_justify_content_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, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"items": ["X"], "height": "300px", "content_align": "bottom"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "justify-content:flex-end;" in html
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 "&lt;script&gt;" 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 "&lt;script&gt;" 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_content_align_renders_on_the_figure_when_a_caption_is_present(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur" —
# n'a de sens QUE sur la figure (conteneur flex-colonne à plusieurs
# enfants réels : image + légende), jamais sur l'<img> seul.
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",
"height": "400px",
"content_align": "center",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert '<figure class="docImageFigure" style="height:400px; justify-content:center;">' in html
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_all_new_formatting_fields(tmp_support_slug_cleanup: Any) -> None:
"""Audit du 26/09/2026 (réglages manquants — colonne "Bouton") : chaque
nouveau champ doit exister avec une valeur "neutre" par défaut
(comportement historique inchangé, voir _render_button)."""
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
attrs = element["attributes"]
assert attrs["label"] == "Bouton"
assert attrs["target"] == ""
assert attrs["attachment_stored_name"] == ""
assert attrs["attachment_filename"] == ""
assert attrs["bold"] is False
assert attrs["italic"] is False
assert attrs["text_transform"] == "none"
assert attrs["font_family"] == ""
assert attrs["font_size"] == ""
assert attrs["letter_spacing"] == ""
assert attrs["text_color"] == ""
assert attrs["svg_markup"] == ""
assert attrs["icon_position"] == "before"
assert attrs["icon_size"] == ""
assert attrs["padding"] == ""
assert attrs["align_self"] == "stretch"
assert attrs["border"] == doc_engine_box_default_border()
html = doc_engine.render_document([element])
assert html == (
f'<button type="button" class="docButton" data-element-id="{element_id}" '
f'data-kind="bouton"><span class="docButtonLabel">Bouton</span></button>'
)
def test_bouton_bold_and_italic_render_without_lowering_default_weight(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": "X", "bold": True, "italic": True})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "font-weight:800;" in html
assert "font-style:italic;" in html
def test_bouton_typography_attributes_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, "bouton", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"label": "X",
"text_transform": "uppercase",
"font_family": "Georgia, serif",
"font_size": "18px",
"letter_spacing": "0.05em",
"text_color": "#ffffff",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "text-transform:uppercase;" in html
assert "font-family:Georgia, serif;" in html
assert "font-size:18px;" in html
assert "letter-spacing:0.05em;" in html
assert "color:#ffffff;" in html
def test_bouton_icon_renders_before_by_default_and_after_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, "bouton", page_id=page_id)
svg = '<svg><circle cx="1" cy="1" r="1"/></svg>'
doc_engine.update_document_element_attributes(slug, element_id, {"label": "X", "svg_markup": svg})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert html.index("docButtonIcon") < html.index("docButtonLabel")
doc_engine.update_document_element_attributes(
slug, element_id, {"label": "X", "svg_markup": svg, "icon_position": "after"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert html.index("docButtonLabel") < html.index("docButtonIcon")
def test_bouton_icon_size_sets_explicit_width_and_height(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": "X", "svg_markup": "<svg></svg>", "icon_size": "24px"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'style="width:24px; height:24px;"' in html
def test_bouton_svg_markup_is_sanitized(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)
malicious = '<svg onload="alert(1)"><script>alert(2)</script></svg>'
doc_engine.update_document_element_attributes(slug, element_id, {"label": "X", "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
def test_bouton_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, "bouton", 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,
{
"label": "X",
"padding": "12px 20px",
"margin": "5px",
"background_color": "#fafafa",
"border_radius": "4px",
"align_self": "center",
"border": border,
"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:12px 20px;" 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 "box-shadow:0 4px 12px rgba(0,0,0,.2);" in html
assert "opacity:0.9;" in html
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