Audit complet de mise en forme — Image (2e élément du tableau)
Implémente toutes les options manquantes identifiées pour l'élément Image : dimensionnement/ratio/object-fit, filtres CSS, upload de fichier (en plus de l'URL), lien/plein écran au clic, chargement différé, légende, et tous les attributs de boîte partagés déjà créés pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/ position du bloc). Système de pages : un support peut désormais avoir 0 page (un nouveau support démarre vide), suppression de toutes les pages en un clic, et la pagination automatique insère intelligemment la nouvelle page juste après celle qui déborde plutôt qu'en toute fin de liste. Bugs réels trouvés et corrigés en cours de route : le style de bloc (dont align-self) ciblait l'élément interne au lieu de son enveloppe (légende/lien) ; une image à sa taille native pouvait déclencher une pagination infinie ; upload/mise à jour d'attribut ne déclenchaient jamais le contrôle de débordement. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6c7675fad0
commit
9ad50c58b8
+11
-1
@@ -205,12 +205,22 @@ def support(client: FlaskClient) -> Generator[str, None, None]:
|
||||
"""Crée un support de formation de test frais via /documents/new
|
||||
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
|
||||
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
|
||||
test, quel que soit son résultat."""
|
||||
test, quel que soit son résultat.
|
||||
|
||||
Un support neuf n'a plus aucune page par défaut (retour utilisateur
|
||||
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
|
||||
lui en crée une par convénience, puisque la quasi-totalité des tests
|
||||
existants portent sur du contenu et présupposent une première page
|
||||
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
|
||||
spécifiquement sur l'état "0 page" créent leur propre support brut
|
||||
via db.create_support(...) plutôt que d'utiliser cette fixture."""
|
||||
import db.supports
|
||||
import document_engine
|
||||
|
||||
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||
document_engine.add_document_page(slug)
|
||||
yield slug
|
||||
if os.path.isdir(db.supports.support_dir(slug)):
|
||||
db.delete_support(slug)
|
||||
|
||||
@@ -16,9 +16,14 @@ from document_engine.rendering.box_style import default_border as doc_engine_box
|
||||
|
||||
|
||||
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.list_document_pages(slug)[0]["id"]
|
||||
page_id = doc_engine.add_document_page(slug)
|
||||
return slug, page_id
|
||||
|
||||
|
||||
@@ -468,7 +473,25 @@ def test_image_default_attributes_include_empty_svg_markup(tmp_support_slug_clea
|
||||
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": ""}
|
||||
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:
|
||||
@@ -499,6 +522,176 @@ def test_image_svg_markup_strips_script_tag(tmp_support_slug_cleanup: Any) -> No
|
||||
assert "alert(" not in html
|
||||
|
||||
|
||||
def test_image_object_fit_renders_only_known_values(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "cover"}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "object-fit:cover;" in html
|
||||
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "n-importe-quoi"}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "object-fit" not in html
|
||||
|
||||
|
||||
def test_image_aspect_ratio_renders_and_is_escaped(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "aspect_ratio": "16 / 9"}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "aspect-ratio:16 / 9;" in html
|
||||
|
||||
|
||||
def test_image_filter_preset_maps_to_a_real_css_filter(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "filter_preset": "grayscale"}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "filter:grayscale(1);" in html
|
||||
|
||||
|
||||
def test_image_lazy_load_adds_the_loading_attribute(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "lazy_load": True}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert 'loading="lazy"' in html
|
||||
|
||||
|
||||
def test_image_caption_wraps_in_a_figure(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "caption": "Une légende <script>x</script>"}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert '<figure class="docImageFigure">' in html
|
||||
assert '<figcaption class="docImageCaption">' in html
|
||||
assert "<script>" not in html
|
||||
assert "<script>" in html
|
||||
|
||||
|
||||
def test_image_click_behavior_link_wraps_in_an_anchor(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{
|
||||
"src": "https://exemple.test/a.png",
|
||||
"alt": "",
|
||||
"click_behavior": "link",
|
||||
"link_url": "https://exemple.test/cible",
|
||||
},
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert (
|
||||
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer">' in html
|
||||
)
|
||||
|
||||
|
||||
def test_image_click_behavior_link_without_url_does_not_wrap(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "link", "link_url": ""}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "docImageLink" not in html
|
||||
|
||||
|
||||
def test_image_click_behavior_lightbox_wraps_in_a_trigger_div(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "lightbox"}
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert '<div class="docImageLightboxTrigger">' in html
|
||||
|
||||
|
||||
def test_image_box_style_attributes_render_and_placeholder_gets_them_too(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
border = doc_engine_box_default_border()
|
||||
border["left"] = {"style": "dotted", "width": "2px", "color": "#00ff00"}
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{
|
||||
"src": "",
|
||||
"alt": "",
|
||||
"padding": "8px",
|
||||
"border_radius": "20px",
|
||||
"width": "300px",
|
||||
"border": border,
|
||||
},
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert "docImagePlaceholder" in html
|
||||
assert "padding:8px;" in html
|
||||
assert "border-radius:20px;" in html
|
||||
assert "width:300px;" in html
|
||||
assert "border-left:2px dotted #00ff00;" in html
|
||||
|
||||
|
||||
def test_image_box_style_targets_the_figure_when_a_caption_is_present(tmp_support_slug_cleanup: Any) -> None:
|
||||
# Bug réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
|
||||
# pas sur l'image" — align-self (et le reste de render_box_style) doit
|
||||
# se poser sur l'élément RÉELLEMENT top-niveau (l'enfant direct du
|
||||
# flex-column de la page), jamais sur l'<img> interne quand une
|
||||
# légende l'enveloppe : sans quoi align-self n'a aucun effet visuel.
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{
|
||||
"src": "https://exemple.test/a.png",
|
||||
"alt": "",
|
||||
"caption": "Légende",
|
||||
"align_self": "center",
|
||||
"width": "300px",
|
||||
},
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert '<figure class="docImageFigure" style="width:300px; align-self:center;">' in html
|
||||
# align-self ne doit apparaître qu'UNE fois (sur la figure), jamais
|
||||
# aussi sur l'<img> interne (ce qui n'aurait aucun effet CSS).
|
||||
assert html.count("align-self") == 1
|
||||
|
||||
|
||||
def test_image_box_style_targets_the_link_wrapper_when_no_caption(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
doc_engine.update_document_element_attributes(
|
||||
slug,
|
||||
element_id,
|
||||
{
|
||||
"src": "https://exemple.test/a.png",
|
||||
"alt": "",
|
||||
"click_behavior": "link",
|
||||
"link_url": "https://exemple.test/cible",
|
||||
"align_self": "center",
|
||||
},
|
||||
)
|
||||
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||
assert (
|
||||
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer" '
|
||||
'style="align-self:center;">' in html
|
||||
)
|
||||
|
||||
|
||||
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Routes HTTP des pages d'un support de formation (routes/document/
|
||||
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
|
||||
implémenter un système de page". Un support a toujours au moins une page
|
||||
(créée par défaut avec le support, voir la fixture `support`)."""
|
||||
implémenter un système de page". La fixture `support` crée une première
|
||||
page par convénience pour ces tests (voir tests/conftest.py) ; en
|
||||
production un support neuf n'a plus aucune page, et supprimer la
|
||||
dernière page restante est désormais autorisé (retour utilisateur du
|
||||
26/09/2026, voir document_engine/pages/pages.md)."""
|
||||
|
||||
from flask.testing import FlaskClient
|
||||
|
||||
@@ -50,12 +53,29 @@ def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, suppo
|
||||
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
|
||||
|
||||
|
||||
def test_document_page_delete_refuses_the_last_remaining_page(client: FlaskClient, support: str) -> None:
|
||||
def test_document_page_delete_allows_deleting_the_last_remaining_page(client: FlaskClient, support: str) -> None:
|
||||
page_id = _page_id(support)
|
||||
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
|
||||
assert resp.status_code == 400
|
||||
assert "error" in resp.get_json()
|
||||
assert document_engine.get_document_page(support, page_id) is not None
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
assert document_engine.get_document_page(support, page_id) is None
|
||||
assert document_engine.list_document_pages(support) == []
|
||||
|
||||
|
||||
def test_document_page_delete_all_wipes_every_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
||||
first_page_id = _page_id(support)
|
||||
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
|
||||
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
|
||||
element_id = element_resp.get_json()["id"]
|
||||
|
||||
resp = client.post(f"/document/{support}/pages/delete-all")
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["ok"] is True
|
||||
|
||||
assert document_engine.list_document_pages(support) == []
|
||||
assert document_engine.get_document_page(support, first_page_id) is None
|
||||
assert document_engine.get_document_page(support, second_page_id) is None
|
||||
assert document_engine.get_document_element(support, element_id) is None
|
||||
|
||||
|
||||
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
||||
|
||||
@@ -39,6 +39,19 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_document_edit_works_on_a_support_with_zero_pages(client: FlaskClient, support: str) -> None:
|
||||
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
|
||||
# obliger d'avoir une page active ou créer, il peut etre ouvert sans
|
||||
# aucune page" — un support neuf (ou vidé via "Supprimer toutes les
|
||||
# pages") n'a plus de page active, l'éditeur ne doit pas planter.
|
||||
client.post(f"/document/{support}/pages/delete-all")
|
||||
resp = client.get(f"/document/{support}/edit")
|
||||
assert resp.status_code == 200
|
||||
html = resp.get_data(as_text=True)
|
||||
assert "activePageId: null" in html
|
||||
assert "pages: []" in html
|
||||
|
||||
|
||||
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
||||
assert resp.status_code == 200
|
||||
@@ -417,6 +430,72 @@ def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient,
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def _add_image(client: FlaskClient, support: str) -> int:
|
||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "image", "page_id": _page_id(support)})
|
||||
return int(resp.get_json()["id"])
|
||||
|
||||
|
||||
def test_upload_image_sets_src_and_clears_svg_markup(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_image(client, support)
|
||||
document_engine.update_document_element_attributes(support, element_id, {"svg_markup": "<svg></svg>"})
|
||||
resp = client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-image",
|
||||
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.get_json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["attributes"]["svg_markup"] == ""
|
||||
src = payload["attributes"]["src"]
|
||||
assert f"/document/{support}/uploads/" in src
|
||||
assert f'src="{src}"' in payload["rendered_html"]
|
||||
|
||||
|
||||
def test_upload_image_rejects_a_non_image_element(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_bouton(client, support)
|
||||
resp = client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-image",
|
||||
data={"file": (io.BytesIO(b"peu importe"), "x.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_upload_image_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_image(client, support)
|
||||
resp = client.post(f"/document/{support}/elements/{element_id}/upload-image", data={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_upload_image_rejects_a_disallowed_extension(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_image(client, support)
|
||||
resp = client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-image",
|
||||
data={"file": (io.BytesIO(b"#!/bin/sh"), "script.sh")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_uploaded_file_serves_the_stored_image(client: FlaskClient, support: str) -> None:
|
||||
element_id = _add_image(client, support)
|
||||
resp = client.post(
|
||||
f"/document/{support}/elements/{element_id}/upload-image",
|
||||
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
|
||||
content_type="multipart/form-data",
|
||||
)
|
||||
src = resp.get_json()["attributes"]["src"]
|
||||
resp = client.get(src)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data == b"fake png bytes"
|
||||
|
||||
|
||||
def test_uploaded_file_404s_for_an_unknown_filename(client: FlaskClient, support: str) -> None:
|
||||
resp = client.get(f"/document/{support}/uploads/inconnu.png")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
|
||||
resp = client.get(f"/document/{support}/edit")
|
||||
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
|
||||
|
||||
@@ -16,6 +16,16 @@ def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_clea
|
||||
assert meta["name"] == "Sécurité incendie"
|
||||
|
||||
|
||||
def test_new_support_has_no_page_by_default(tmp_support_slug_cleanup: Any) -> None:
|
||||
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
|
||||
# obliger d'avoir une page active" — voir document_engine/pages/pages.md.
|
||||
import document_engine
|
||||
|
||||
slug = db.create_support("Nouveau projet", owner_folder="52")
|
||||
tmp_support_slug_cleanup(slug)
|
||||
assert document_engine.list_document_pages(slug) == []
|
||||
|
||||
|
||||
def test_list_supports_scopes_to_owner_and_excludes_games(
|
||||
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
|
||||
) -> None:
|
||||
|
||||
Reference in New Issue
Block a user