Files
Forge-Engine/tests/document/test_document_elements.py
T
williamandClaude Sonnet 5 746feb3796 Ajoute l'alignement vertical du contenu d'une page, réglable depuis l'onglet "Pages"
Nouvelle colonne _document_pages.vertical_align (top/center/bottom,
"top" par défaut, migration incluse pour les supports existants).
Quand l'onglet "Pages" du panneau gauche est actif, le panneau
Propriétés (droite) affiche maintenant l'alignement de la page active
au lieu des propriétés d'un élément — un contrôle segmenté qui persiste
via une nouvelle route dédiée et met à jour le canevas immédiatement.

Le contenu-seed des thèmes porte désormais aussi ce réglage par page
(seed_pages devient une liste de {vertical_align, blocks} plutôt qu'une
liste de listes de blocs) : la page de titre du thème "Sécurité
Incendie" est centrée verticalement, comme demandé, cohérente avec la
maquette d'origine. L'aperçu de thème (iframe de la modale) reflète
aussi ce réglage par page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 10:24:48 +02:00

403 lines
19 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
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
slug = db.create_support(name, owner_folder="90")
tmp_support_slug_cleanup(slug)
page_id = doc_engine.list_document_pages(slug)[0]["id"]
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_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_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"}
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_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
assert element["attributes"] == {"src": "", "alt": "", "svg_markup": ""}
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_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