Files
Forge-Engine/tests/document/test_document_routes.py
T
williamandClaude Sonnet 5 c6e173589f Pagination automatique : le contenu qui déborde part sur une nouvelle page
Retour utilisateur du 23/09/2026 : "si il n'y a plus de place sur la
page il faut automatiquement créer une autre page [et y] coller le
contenu et amener l'utilisateur sur la page" — remplace le
comportement précédent (overflow:hidden, contenu clipsé, à gérer
manuellement).

- Nouvelle capacité serveur : document_engine.move_document_element_to_page
  (+ route POST /document/<slug>/elements/<id>/move-to-page) déplace un
  élément (et ses enfants de rangée en cascade) vers une AUTRE page —
  jusqu'ici move_document_element ne gérait que le réordonnancement DANS
  la même page.
- Client : forgeDocCheckPageOverflow, appelée à la fin de CHAQUE
  forgeDocRefreshCanvas (point d'entrée unique après toute mutation) :
  mesure le débordement réel (scrollHeight vs clientHeight), trouve le
  premier élément top-niveau qui dépasse le bas de la page
  (getBoundingClientRect, tient compte du zoom), déplace cet élément et
  tout ce qui le suit vers une page neuve, puis y bascule l'utilisateur.
  Jamais déclenché sur une page mini-jeu (toujours seule sur sa page,
  aucun débordement pertinent à corriger).

Vérifié par un test jsdom dédié (géométrie simulée via
getBoundingClientRect/scrollHeight/clientHeight, jsdom n'ayant pas de
vrai moteur de mise en page) : ordre des déplacements, page inchangée
si le contenu tient, page mini-jeu jamais scindée. 6 nouveaux tests
Python (document_engine + route). 711/711 tests passent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 11:50:23 +02:00

328 lines
14 KiB
Python

"""Routes HTTP du support de formation (routes/document/) — création,
édition directe (sans page intermédiaire), CRUD des éléments en AJAX,
publication, et isolation par propriétaire (core/auth_guard.py).
Chaque élément appartient désormais à une page (voir document_engine/
pages/ et test_document_pages_routes.py pour les routes dédiées aux
pages elles-mêmes) — _page_id() renvoie l'id de la page par défaut
("Page 1") créée avec chaque support de test, réutilisé par toutes les
routes d'élément ci-dessous qui exigent désormais un page_id explicite."""
from typing import Any
from flask.testing import FlaskClient
import auth
import db
import document_engine
def _page_id(support: str) -> int:
return int(document_engine.list_document_pages(support)[0]["id"])
def test_documents_new_creates_a_support_not_a_game(client: FlaskClient) -> None:
resp = client.post("/documents/new", data={"name": "Sécurité incendie"}, follow_redirects=False)
assert resp.status_code == 302
assert "/document/" in resp.headers["Location"]
assert "/edit" in resp.headers["Location"]
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
try:
assert db.support_meta(slug)["name"] == "Sécurité incendie"
finally:
db.delete_support(slug)
def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/edit")
assert resp.status_code == 200
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
payload = resp.get_json()
assert payload["kind"] == "titre"
assert "docText" in payload["rendered_html"]
def test_document_element_add_rejects_unknown_kind(client: FlaskClient, support: str) -> None:
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "n-importe-quoi", "page_id": _page_id(support)}
)
assert resp.status_code == 400
def test_document_element_add_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).status_code == 404
assert client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": 999}).status_code == 404
def test_document_element_add_accepts_row_for_the_layout_engine(client: FlaskClient, support: str) -> None:
""" "row" n'apparaît jamais dans la bibliothèque (voir
document_engine.ELEMENT_LIBRARY) mais reste un kind valide pour cette
route — c'est le moteur de layout côté client qui en crée une à la
volée au moment d'un dépôt latéral (voir
static/document/js/document-editor.js)."""
resp = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": _page_id(support)})
assert resp.status_code == 200
assert resp.get_json()["kind"] == "row"
def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={"content": "Texte mis à jour", "style": "legende"},
)
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
reread = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
assert reread.status_code == 200
def test_document_element_update_sanitizes_quiz_config_on_write(client: FlaskClient, support: str) -> None:
"""Quiz et Association sont revalidés côté serveur (voir
routes/document/document_element_update.py) : un choix en trop
(5e) doit être tronqué, et la réponse renvoie les attributs
RÉELLEMENT persistés (pas ceux envoyés tels quels) pour que le
client ne dérive jamais de la vérité serveur."""
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={
"theme_color": "#ff5f2e",
"timer_enabled": False,
"timer_seconds": 30,
"questions": [
{"text": "Une question", "choices": ["A", "B", "C", "D", "E"], "correct_index": 0, "points": 10},
],
},
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["questions"][0]["choices"] == ["A", "B", "C", "D"]
assert "1 question" in payload["rendered_html"]
def test_document_element_update_sanitizes_association_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(
f"/document/{support}/elements/add", data={"kind": "association", "page_id": _page_id(support)}
)
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={
"theme_color": "#ffb020",
"pairs": [
{"left": "Chat", "right": "Un félin"},
{"left": "Sans correspondance", "right": ""},
],
},
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["pairs"] == [{"left": "Chat", "right": "Un félin"}]
assert "1 paire" in payload["rendered_html"]
def test_document_element_update_sanitizes_memory_config_on_write(client: FlaskClient, support: str) -> None:
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "memory", "page_id": _page_id(support)})
element_id = add_resp.get_json()["id"]
resp = client.post(
f"/document/{support}/elements/{element_id}/update",
json={
"theme_color": "#ffb020",
"mode": "single",
"cards": [
{"recto": {"text": "?"}, "verso": {"text": "Chat"}},
{"recto": {"text": "?"}, "verso": {"image": "", "text": ""}},
],
},
)
assert resp.status_code == 200
payload = resp.get_json()
assert payload["ok"] is True
assert payload["attributes"]["mode"] == "single"
assert len(payload["attributes"]["cards"]) == 1
assert "1 carte" in payload["rendered_html"]
assert "mode simple" in payload["rendered_html"]
def test_document_edit_sanitizes_stale_scenario_attributes_from_an_old_schema(
client: FlaskClient, support: str
) -> None:
"""Bug réel constaté le 21/09/2026 : un élément Scénario créé AVANT la
refonte en arbre de décision (ancien schéma plat "situation"/"choices"/
"correct_index", sans "title"/"nodes") faisait planter silencieusement
le panneau Propriétés côté client (`scenario.nodes` inexistant), sans
qu'aucune erreur ne remonte — voir document_engine/labels/
sanitize_element_attributes.py. `update_document_element_attributes`
(bas niveau, jamais utilisée directement par une route) sert ici à
écrire ce schéma obsolète tel quel, en contournant volontairement le
sanitize de la route d'update — pour simuler une ligne réellement
ancienne en base, jamais nettoyée depuis."""
page_id = _page_id(support)
add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "scenario", "page_id": page_id})
element_id = add_resp.get_json()["id"]
stale_attributes = {
"theme_color": "#ff5f2e",
"scenarios": [
{
"situation": "Un chat va se faire renverser sous vos yeux, que faites-vous ?",
"choices": [{"text": "Je le sauve", "consequence": "Le chat est sauvé."}],
"correct_index": 0,
}
],
}
document_engine.update_document_element_attributes(support, element_id, stale_attributes)
edit_resp = client.get(f"/document/{support}/edit")
assert edit_resp.status_code == 200
render_resp = client.get(f"/document/{support}/render", query_string={"page_id": page_id})
assert render_resp.status_code == 200
rendered_element = next(el for el in render_resp.get_json()["elements"] if el["id"] == element_id)
# Le vieux schéma n'a ni "title" ni "nodes" valides : sanitize_scenario_config
# le rejette entièrement plutôt que de renvoyer une structure à moitié
# ancienne/à moitié nouvelle — c'est la structure ACTUELLE garantie qui
# compte ici, jamais un plantage silencieux côté client.
assert rendered_element["attributes"] == document_engine.DEFAULT_SCENARIO_CONFIG
def test_document_render_rejects_a_missing_or_unknown_page(client: FlaskClient, support: str) -> None:
assert client.get(f"/document/{support}/render").status_code == 404
assert client.get(f"/document/{support}/render", query_string={"page_id": 999}).status_code == 404
def test_document_element_add_rejects_a_minigame_on_a_non_empty_page(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : "un mini jeu dois occupper
toute une page" -> "une page avec mini-jeu = uniquement ce
mini-jeu". Un mini-jeu ne peut donc jamais rejoindre une page qui a
déjà du contenu."""
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
assert resp.status_code == 400
assert "mini-jeu" in resp.get_json()["error"]
def test_document_element_add_rejects_other_content_on_a_page_with_a_minigame(
client: FlaskClient, support: str
) -> None:
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id})
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
assert resp.status_code == 400
assert "mini-jeu" in resp.get_json()["error"]
def test_document_element_add_rejects_a_minigame_inside_a_row(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
row_id = client.post(f"/document/{support}/elements/add", data={"kind": "row", "page_id": page_id}).get_json()["id"]
resp = client.post(
f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": page_id, "parent_id": row_id}
)
assert resp.status_code == 400
def test_document_element_add_allows_a_lone_minigame_on_an_empty_page(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/elements/add", data={"kind": "quiz", "page_id": _page_id(support)})
assert resp.status_code == 200
def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}).get_json()[
"id"
]
second_id = client.post(
f"/document/{support}/elements/add", data={"kind": "paragraphe", "page_id": page_id}
).get_json()["id"]
move_resp = client.post(
f"/document/{support}/elements/{second_id}/move",
json={"new_parent_id": None, "new_index": 0},
)
assert move_resp.status_code == 200
assert move_resp.get_json()["ok"] is True
delete_resp = client.post(f"/document/{support}/elements/{first_id}/delete")
assert delete_resp.status_code == 200
assert delete_resp.get_json()["ok"] is True
def test_document_element_move_to_page_transfers_the_element(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : pagination automatique — un
élément qui déborde d'une page est déplacé vers une autre, ici la
route qui porte ce déplacement (jamais document_element_move, qui
ne fait que réordonner DANS la même page)."""
page1_id = _page_id(support)
page2_id = document_engine.add_document_page(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page1_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": page2_id})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
moved = document_engine.get_document_element(support, element_id)
assert moved is not None
assert moved["page_id"] == page2_id
def test_document_element_move_to_page_rejects_an_unknown_target(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": 999})
assert resp.status_code == 404
def test_restricted_user_can_have_one_game_and_one_support_at_once(
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
) -> None:
with user_client.session_transaction() as sess:
user_id = sess["user_id"]
game_slug = db.create_game("Un environnement 2D", owner_folder=str(user_id))
tmp_game_slug_cleanup(game_slug)
auth.set_project_slug(user_id, game_slug)
resp = user_client.post("/documents/new", data={"name": "Un support"}, follow_redirects=False)
assert resp.status_code == 302
support_slug = resp.headers["Location"].rstrip("/").split("/")[-2]
tmp_support_slug_cleanup(support_slug)
user = auth.get_user_by_id(user_id)
assert user is not None
assert user["project_slug"] == game_slug
assert user["support_slug"] == support_slug
def test_cannot_open_another_owners_support(client: FlaskClient, user_client: FlaskClient) -> None:
"""Isolation par propriétaire (core/auth_guard.py, split_slug déjà
générique quel que soit le type de projet) — le compte admin
(`client`) ne doit pas pouvoir ouvrir le support d'un autre compte."""
with user_client.session_transaction() as sess:
victim_user_id = sess["user_id"]
victim_slug = db.create_support("Support de la victime", owner_folder=str(victim_user_id))
try:
assert client.get(f"/document/{victim_slug}/edit").status_code == 403
assert client.post(f"/document/{victim_slug}/delete").status_code == 403
finally:
db.delete_support(victim_slug)