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>
This commit is contained in:
william
2026-09-26 15:13:14 +02:00
co-authored by Claude Sonnet 5
parent 8f879f6321
commit 4db1269348
17 changed files with 1080 additions and 34 deletions
+19 -1
View File
@@ -3,7 +3,7 @@ document_engine/rendering/box_style.py) — testés une seule fois ici,
indépendamment de chaque kind qui les utilise (voir aussi
test_document_elements.py pour leur usage réel via _render_text)."""
from document_engine.rendering.box_style import BORDER_SIDES, default_border, render_box_style
from document_engine.rendering.box_style import BORDER_SIDES, default_border, render_box_style, render_content_align
def test_default_border_has_all_four_sides_set_to_none() -> None:
@@ -76,3 +76,21 @@ def test_render_box_style_escapes_malicious_values() -> None:
border["top"] = {"style": "solid", "width": "1px", "color": '"><script>alert(1)</script>'}
style = render_box_style({"padding": '"><script>alert(2)</script>', "border": border})
assert "<script>" not in style
def test_render_content_align_is_empty_for_default_top() -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur" —
# "top" est le comportement historique (contenu en haut), jamais un
# style ajouté pour ne rien changer par défaut.
assert render_content_align({}) == ""
assert render_content_align({"content_align": "top"}) == ""
def test_render_content_align_renders_center_and_bottom() -> None:
assert render_content_align({"content_align": "center"}) == "justify-content:center;"
assert render_content_align({"content_align": "bottom"}) == "justify-content:flex-end;"
def test_render_content_align_ignores_unknown_values() -> None:
assert render_content_align({"content_align": "n-importe-quoi"}) == ""
+239
View File
@@ -161,6 +161,18 @@ def test_titre_box_style_attributes_render_via_shared_box_style(tmp_support_slug
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
@@ -356,6 +368,211 @@ def test_liste_puces_with_no_items_renders_an_empty_list(tmp_support_slug_cleanu
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)
@@ -671,6 +888,28 @@ def test_image_box_style_targets_the_figure_when_a_caption_is_present(tmp_suppor
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)
+33
View File
@@ -548,6 +548,39 @@ def test_document_theme_apply_template_content_replaces_everything(client: Flask
assert len(pages) == len(theme["seed_pages"])
def test_document_theme_apply_with_empty_theme_id_removes_the_theme(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — theme_id vide est le signal que la route interprète comme
# un retrait, jamais comme une erreur de validation.
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "keep_content"},
)
assert db.get_document_theme(support) == "securite-incendie"
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": ""})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "theme_id": ""}
# Retire vraiment la ligne _meta (voir db/supports/remove_document_theme.py)
# — jamais une chaîne vide qui violerait le contrat documenté de
# get_document_theme ("None" = aucun thème jamais appliqué/retiré).
assert db.get_document_theme(support) is None
# Le contenu n'est JAMAIS touché en retirant le thème.
assert len(document_engine.list_document_elements(support, page_id)) == 1
edit_resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in edit_resp.get_data(as_text=True)
def test_document_theme_apply_with_empty_theme_id_ignores_mode(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": "", "mode": "n-importe-quoi"})
assert resp.status_code == 200
assert db.get_document_theme(support) is None
def test_document_theme_preview_renders_every_seed_page_with_navigation(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/theme/securite-incendie/preview")
assert resp.status_code == 200
+22
View File
@@ -92,3 +92,25 @@ def test_set_document_theme_can_be_changed(tmp_support_slug_cleanup: Any) -> Non
db.set_document_theme(slug, "securite-incendie")
db.set_document_theme(slug, "autre-theme")
assert db.get_document_theme(slug) == "autre-theme"
def test_remove_document_theme_resets_to_none(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — get_document_theme doit redevenir None, jamais une chaîne
# vide (contrat documenté dans db/supports/get_document_theme.py).
slug = db.create_support("Retire son thème", owner_folder="54")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
assert db.get_document_theme(slug) == "securite-incendie"
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None
assert db.support_meta(slug)["theme"] is None
def test_remove_document_theme_is_a_noop_when_none_was_ever_set(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Jamais de thème", owner_folder="55")
tmp_support_slug_cleanup(slug)
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None