Nouveau bouton dans le topbar de l'éditeur, à côté d'Aperçu, qui ouvre une modale listant les thèmes du catalogue (document_engine/themes/). Cliquer un thème charge un VRAI aperçu (rendu serveur réel dans un iframe, jamais une resucée CSS côté client) avec le choix de garder le contenu actuel ou de le remplacer par le contenu de démonstration du modèle. Architecture pensée pour une centaine de thèmes futurs : chaque thème est une feuille de style externe (static/document/themes/<id>.css) qui habille les classes fixes du moteur, jamais du code qui en changerait la structure. Premier thème implémenté pour valider le mécanisme : "Sécurité Incendie" (6 pages de contenu réel, quiz inclus). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
396 lines
19 KiB
Python
396 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 "<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"}
|
|
|
|
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_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[list[dict[str, Any]]] = [
|
|
[
|
|
{"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"}},
|
|
],
|
|
},
|
|
],
|
|
[{"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
|
|
|
|
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, [[{"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
|