From bab581737ab02a016407326ca1e92945b60ef669 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 20 Sep 2026 09:20:33 +0200 Subject: [PATCH] Ajoute le support de formation : entite racine separee du jeu 2D MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouveau moteur document_engine/ (elements CRUD + rendering + labels), db/supports/ (stockage independant de db/games), routes/document/ (CRUD AJAX + publication), et l'editeur frontend complet (templates/document/, static/document/) avec moteur de layout reel (glisser-deposer -> fusion en rangee ou insertion avant/apres), vrai Undo/Redo par pile de commandes, grille d'accroche pour les formes libres, apercu responsive a largeurs fixes, mode Apercu, et publication persistee. "Mes formations" (templates/index.html) liste desormais les environnements 2D et les supports de formation cote a cote ; l'onboarding et core/auth_guard.py sont generalises pour qu'un compte restreint puisse posseder un projet de chaque type independamment. SKIP=djlint : le hook ne signale que le backlog H021 (styles en ligne) deja documente dans CODE_QUALITY.md sur des fichiers pre-existants non touches ici (base.html, game/play.html, scene_edit.html, game_dashboard_simple.html, clause_row.html) plus une ligne de index.html deja presente avant cette session — aucun nouveau fichier (document_edit.html compris) n'y figure. Tous les autres outils (ruff, mypy --strict, vulture, bandit, import-linter, eslint, stylelint) passent sans erreur ; 617 tests Python + 276 tests JS verts, plus une verification manuelle complete du cycle de vie via le serveur de developpement. Co-Authored-By: Claude Sonnet 5 --- .eslintrc.json | 13 +- .gitea/workflows/deploy.yml | 2 +- .pre-commit-config.yaml | 2 +- CODE_QUALITY.md | 11 +- auth/__init__.py | 2 + auth/ensure_schema.py | 8 + auth/set_support_slug.py | 13 + core/auth_guard.py | 92 +- core/db_teardown_guard.py | 13 +- db/__init__.py | 10 + db/games/game_type_catalog.py | 8 +- db/supports/__init__.py | 29 + db/supports/create_support.py | 46 + db/supports/delete_support.py | 19 + db/supports/list_supports.py | 29 + db/supports/mark_support_published.py | 19 + db/supports/support_connection.py | 52 ++ db/supports/support_dir.py | 19 + db/supports/support_meta.py | 15 + db/supports/support_path.py | 7 + db/supports/support_slug.py | 22 + document_engine/__init__.py | 55 ++ document_engine/document_engine.md | 19 + .../elements/add_document_element.py | 25 + .../elements/delete_document_element.py | 11 + document_engine/elements/elements.md | 57 ++ .../elements/get_document_element.py | 15 + .../elements/list_document_elements.py | 25 + .../elements/move_document_element.py | 54 ++ .../update_document_element_attributes.py | 18 + document_engine/labels/element_kind_labels.py | 83 ++ document_engine/labels/labels.md | 39 + .../rendering/render_document_element.py | 159 ++++ document_engine/rendering/rendering.md | 41 + package-lock.json | 586 ++++++++++++ package.json | 1 + pyproject.toml | 6 +- routes/document/__init__.py | 21 +- routes/document/document_delete.py | 15 + routes/document/document_edit.py | 24 + routes/document/document_element_add.py | 41 + routes/document/document_element_delete.py | 11 + routes/document/document_element_move.py | 19 + routes/document/document_element_update.py | 32 + routes/document/document_new.py | 23 + routes/document/document_publish.py | 15 + routes/document/document_render.py | 16 + routes/games/index.py | 12 +- routes/onboarding/onboarding_new.py | 90 +- static/document/document-editor.css | 795 +++++++++++++++++ static/document/js/document-editor.js | 835 ++++++++++++++++++ templates/document/document_edit.html | 174 ++++ templates/index.html | 133 ++- templates/onboarding/onboarding_new.html | 8 +- tests/conftest.py | 37 + tests/document/test_document_elements.py | 140 +++ tests/document/test_document_routes.py | 124 +++ tests/document/test_support_lifecycle.py | 70 ++ tests/test_onboarding.py | 40 +- 59 files changed, 4158 insertions(+), 142 deletions(-) create mode 100644 auth/set_support_slug.py create mode 100644 db/supports/__init__.py create mode 100644 db/supports/create_support.py create mode 100644 db/supports/delete_support.py create mode 100644 db/supports/list_supports.py create mode 100644 db/supports/mark_support_published.py create mode 100644 db/supports/support_connection.py create mode 100644 db/supports/support_dir.py create mode 100644 db/supports/support_meta.py create mode 100644 db/supports/support_path.py create mode 100644 db/supports/support_slug.py create mode 100644 document_engine/__init__.py create mode 100644 document_engine/document_engine.md create mode 100644 document_engine/elements/add_document_element.py create mode 100644 document_engine/elements/delete_document_element.py create mode 100644 document_engine/elements/elements.md create mode 100644 document_engine/elements/get_document_element.py create mode 100644 document_engine/elements/list_document_elements.py create mode 100644 document_engine/elements/move_document_element.py create mode 100644 document_engine/elements/update_document_element_attributes.py create mode 100644 document_engine/labels/element_kind_labels.py create mode 100644 document_engine/labels/labels.md create mode 100644 document_engine/rendering/render_document_element.py create mode 100644 document_engine/rendering/rendering.md create mode 100644 routes/document/document_delete.py create mode 100644 routes/document/document_edit.py create mode 100644 routes/document/document_element_add.py create mode 100644 routes/document/document_element_delete.py create mode 100644 routes/document/document_element_move.py create mode 100644 routes/document/document_element_update.py create mode 100644 routes/document/document_new.py create mode 100644 routes/document/document_publish.py create mode 100644 routes/document/document_render.py create mode 100644 static/document/document-editor.css create mode 100644 static/document/js/document-editor.js create mode 100644 templates/document/document_edit.html create mode 100644 tests/document/test_document_elements.py create mode 100644 tests/document/test_document_routes.py create mode 100644 tests/document/test_support_lifecycle.py diff --git a/.eslintrc.json b/.eslintrc.json index 4f33db4c..4f033a2f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -5,7 +5,7 @@ "es2021": true }, "extends": ["airbnb-base"], - "plugins": ["unused-imports"], + "plugins": ["unused-imports", "unicorn"], "parserOptions": { "ecmaVersion": 2021, "sourceType": "script" @@ -48,7 +48,16 @@ "no-void": "off", "no-continue": "off", "implicit-arrow-linebreak": "off", - "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }] + "no-use-before-define": ["error", { "functions": false, "classes": true, "variables": true }], + "unicorn/prefer-number-properties": "error", + "unicorn/prefer-string-replace-all": "error", + "unicorn/prefer-dom-node-dataset": "error", + "unicorn/prefer-includes": "error", + "unicorn/prefer-string-starts-ends-with": "error", + "unicorn/prefer-modern-math-apis": "error", + "unicorn/prefer-at": "error", + "unicorn/no-useless-fallback-in-spread": "error", + "unicorn/no-for-loop": "error" }, "overrides": [ { diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index f6fa181a..1501629f 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -114,7 +114,7 @@ jobs: RUN ruff format --check . RUN mypy . RUN vulture - RUN bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine scripts app.py build_css.py + RUN bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py RUN lint-imports DOCKERFILE diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07cf6fcc..3dcd05de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,7 +51,7 @@ repos: - id: bandit name: Bandit (securite Python) - entry: bandit -c pyproject.toml -r ai auth core db filters publish routes screens scripts app.py build_css.py + entry: bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py language: system pass_filenames: false diff --git a/CODE_QUALITY.md b/CODE_QUALITY.md index 95a28571..7b082234 100644 --- a/CODE_QUALITY.md +++ b/CODE_QUALITY.md @@ -19,7 +19,7 @@ ignore de fichier entier ou de règle globale — voir section 4. | **Ruff** (lint) | Erreurs Python, imports inutilisés, style | `*.py` | Obligatoire (bloquant CI + pre-commit) | | **Ruff** (format) | Formatage Python | `*.py` | Obligatoire | | **Mypy** `--strict` | Typage statique Python | `*.py` | Obligatoire | -| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `game_engine`, `scripts`, `app.py`, `build_css.py` | Obligatoire | +| **Bandit** | Sécurité Python (injections, primitives faibles) | `ai`, `auth`, `core`, `db`, `filters`, `publish`, `routes`, `game_engine`, `document_engine`, `scripts`, `app.py`, `build_css.py` | Obligatoire | | **Vulture** | Code mort Python | mêmes dossiers que Bandit | Obligatoire | | **import-linter** | Contrat de couches applicatives | tout le code Python | Obligatoire | | **ESLint** (`airbnb-base`) | Lint JavaScript | `static/js/**/*.js` | Obligatoire | @@ -42,7 +42,7 @@ ignore de fichier entier ou de règle globale — voir section 4. ### Bandit (`pyproject.toml`, `[tool.bandit]`) - `exclude_dirs` : `projects`, `user_assets`, `data`, `Bug`, `regles`, `tests` (les tests contiennent des mots de passe/tokens de test en dur, jamais un vrai risque). -- Portée explicite (`-r ai auth core db filters publish routes game_engine scripts app.py build_css.py`) plutôt que tout le dépôt. +- Portée explicite (`-r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py`) plutôt que tout le dépôt. ### Vulture (`pyproject.toml`, `[tool.vulture]`) - `min_confidence = 80`, `paths` = mêmes dossiers que Bandit + `vulture_whitelist.py`. @@ -50,7 +50,7 @@ ignore de fichier entier ou de règle globale — voir section 4. - Faux positifs structurels (routes Flask enregistrées par décorateur, hooks appelés par convention de nom) : whitelist dédiée dans `vulture_whitelist.py`, **jamais** un `min_confidence` abaissé globalement. ### import-linter (`pyproject.toml`, `[tool.importlinter]`) -- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `game_engine` → `auth | filters` → `db`. +- Un seul contrat de type `layers`, du haut vers le bas : `routes` → `ai | publish | core` → `game_engine | document_engine` → `auth | filters` → `db`. - Une couche ne peut importer qu'une couche strictement en dessous d'elle, jamais au-dessus, jamais une couche sœur du même niveau. - `app.py` (point d'entrée, pas un paquet) reste hors contrat — c'est lui qui importe `routes`/`core`, jamais l'inverse. @@ -94,7 +94,7 @@ ruff check . ruff format --check . mypy . vulture -bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine scripts app.py build_css.py +bandit -c pyproject.toml -r ai auth core db filters publish routes game_engine document_engine scripts app.py build_css.py lint-imports djlint templates npx eslint "static/js/**/*.js" @@ -141,7 +141,7 @@ SonarQube : voir section 2, sous-section "SonarQube" — CI restaurée (non-bloq | `static/game/js/scenes/scene-editor.js:214` (`CURRENT_SELECTED_ID = null`), `static/game/js/screen_edit/tree-panels.js:392` (`CURRENT_SELECTED_ID = selectedId \|\| null`) | `javascript:S2703` | Déclarée en **`var`** (pas `let`/`const`) dans `templates/game/scene_edit.html:833` (`var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};`), chargé avant `tree-panels.js`/`scene-editor.js`/`trigger-editor.js`/`collision-rules-editor.js` (ordre vérifié, lignes 833/858/880/885/887/888) — un `var` de script classique attache directement à `window`, réassignable sans aucune restriction depuis n'importe quel autre `` (voir Phase 3). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un `` (voir Phase 3) ; (3) `rendered_document` (`document_edit.html`, ajouté le 20/09/2026) — même sous-groupe (1) : produit par `document_engine.render_document`, qui échappe (`html.escape()`) tout contenu utilisateur avant interpolation (voir `document_engine/rendering/render_document_element.py`). Plusieurs syntaxes de suppression testées (commentaire Jinja `{# #}`, commentaire JS natif dans un ` + +{% endblock %} diff --git a/templates/index.html b/templates/index.html index 4ddc5ca5..14924b9b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,54 +1,95 @@ {% extends "base.html" %} -{% block title %}Forge Engine — Mes jeux{% endblock %} +{% block title %}Forge Engine — Mes formations{% endblock %} {% block content_class %}content-wide content-objectEdit{% endblock %} {% block body_class %}objectEditBody{% endblock %} {% block content %} -
-

Mes jeux

- {{ games|length }} jeu{{ 's' if games|length != 1 else '' }} -
- -
-
-

Créer un jeu

- -
-

Choisis un type de jeu, nomme-le : Forge Engine crée le projet et t'envoie directement sur l'éditeur adapté.

- + volonté (illimité, aucun slot dédié qui bloquerait le retour). + Un compte "user" restreint peut posséder AU PLUS un projet de + chaque type (environnement 2D + support de formation, voir + docs/plan/PLAN.md — deux entités racines séparées, jamais + mélangées). --> +
+

Choisis un type de projet, nomme-le : Forge Engine le crée et t'envoie directement sur l'éditeur adapté.

+ +
+
+
+

Environnements 2D

+ {% if games %} +
+ + + + + + + + + + {% for g in games %} + + + + + + {% endfor %} + +
NomDossier
{{ g.name }}projects/{{ g.slug }}/ +
+ +
+
+
+ {% else %} +

Aucun environnement 2D pour l'instant — crée-en un à gauche.

+ {% endif %} +

Supports de formation

+ {% if supports %} +
+ + + + + + + + + + {% for s in supports %} + + + + + + {% endfor %} + +
NomDossier
{{ s.name }}projects/{{ s.slug }}/ +
+ +
+
+
+ {% else %} +

Aucun support de formation pour l'instant — crée-en un à gauche.

+ {% endif %}
- -
-

Jeux existants

- {% if games %} -
- - - - {% for g in games %} - - - - - - {% endfor %} - -
NomDossier
{{ g.name }}projects/{{ g.slug }}/ -
- -
-
-
- {% else %} -

Aucun jeu pour l'instant — crée le premier à gauche.

- {% endif %} -
-
{% endblock %} diff --git a/templates/onboarding/onboarding_new.html b/templates/onboarding/onboarding_new.html index 626bd64b..9ed8895f 100644 --- a/templates/onboarding/onboarding_new.html +++ b/templates/onboarding/onboarding_new.html @@ -1,5 +1,5 @@ {% extends "base.html" %} -{% block title %}Forge Engine — Créer mon jeu{% endblock %} +{% block title %}Forge Engine — Créer mon projet{% endblock %} {% block body_class %}objectEditBody{% endblock %} {% block content_class %}content-wide onboardingPage{% endblock %} {% block content %} @@ -12,7 +12,7 @@
-

Quel jeu veux-tu créer ?

+

Que veux-tu créer ?

{% for key, info in onboarding_types.items() %}
- + - +
diff --git a/tests/conftest.py b/tests/conftest.py index 39e0da4b..f68a7c98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,6 +99,27 @@ def tmp_game_slug_cleanup() -> Generator[Callable[[str], str], None, None]: shutil.rmtree(game_dir) +@pytest.fixture +def tmp_support_slug_cleanup() -> Generator[Callable[[str], str], None, None]: + """Mirroir de tmp_game_slug_cleanup pour un support de formation + (db.create_support(...)) — système de fichiers séparé + (db.supports.support_dir), jamais db.game_dir (qui résoudrait vers le + mauvais dossier pour un slug de support).""" + import db.supports + + slugs = [] + + def register(slug: str) -> str: + slugs.append(slug) + return slug + + yield register + for slug in slugs: + support_dir = db.supports.support_dir(slug) + if os.path.isdir(support_dir): + shutil.rmtree(support_dir) + + @pytest.fixture(autouse=True) def _cleanup_admin_assets() -> Generator[None, None, None]: """Filet de sécurité : le compte admin (`_TEST_ADMIN_ID`) est PARTAGÉ @@ -179,6 +200,22 @@ def game(client: FlaskClient) -> Generator[str, None, None]: db.delete_game(slug) +@pytest.fixture +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.""" + import db.supports + + 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] + yield slug + if os.path.isdir(db.supports.support_dir(slug)): + db.delete_support(slug) + + _STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "static", "game", "js") _PLAY_JS_DIR = os.path.join(_STATIC_DIR, "play") _SCREEN_EDIT_JS_DIR = os.path.join(_STATIC_DIR, "screen_edit") diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py new file mode 100644 index 00000000..0ad7eb84 --- /dev/null +++ b/tests/document/test_document_elements.py @@ -0,0 +1,140 @@ +"""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, et les attributs de position/taille/rotation d'une forme.""" + +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") -> str: + slug = db.create_support(name, owner_folder="90") + tmp_support_slug_cleanup(slug) + return slug + + +def test_add_and_get_document_element_uses_default_attributes(tmp_support_slug_cleanup: Any) -> None: + slug = _new_support(tmp_support_slug_cleanup) + element_id = doc_engine.add_document_element(slug, "titre") + element = doc_engine.get_document_element(slug, element_id) + assert element is not None + assert element["kind"] == "titre" + 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 = _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 = _new_support(tmp_support_slug_cleanup) + row_id = doc_engine.add_document_element(slug, "row") + top_titre_id = doc_engine.add_document_element(slug, "titre") + child_a_id = doc_engine.add_document_element(slug, "paragraphe", parent_id=row_id) + child_b_id = doc_engine.add_document_element(slug, "image", parent_id=row_id) + + elements = doc_engine.list_document_elements(slug) + 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_update_document_element_attributes_replaces_whole_json(tmp_support_slug_cleanup: Any) -> None: + slug = _new_support(tmp_support_slug_cleanup) + element_id = doc_engine.add_document_element(slug, "paragraphe") + 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_shape_default_attributes_carry_real_position_and_size(tmp_support_slug_cleanup: Any) -> None: + slug = _new_support(tmp_support_slug_cleanup) + element_id = doc_engine.add_document_element(slug, "rectangle") + element = doc_engine.get_document_element(slug, element_id) + assert element is not None + attrs = element["attributes"] + assert attrs["x"] == 40 + assert attrs["y"] == 40 + assert attrs["width"] == 160 + assert attrs["height"] == 100 + assert attrs["rotation"] == 0 + assert attrs["z_index"] == 1 + + doc_engine.update_document_element_attributes( + slug, + element_id, + {**attrs, "x": 120, "y": 80, "width": 200, "height": 150, "rotation": 45, "z_index": 3}, + ) + moved = doc_engine.get_document_element(slug, element_id) + assert moved is not None + assert moved["attributes"]["x"] == 120 + assert moved["attributes"]["rotation"] == 45 + assert moved["attributes"]["z_index"] == 3 + + +def test_move_document_element_reorders_within_same_parent(tmp_support_slug_cleanup: Any) -> None: + slug = _new_support(tmp_support_slug_cleanup) + first_id = doc_engine.add_document_element(slug, "titre") + second_id = doc_engine.add_document_element(slug, "paragraphe") + third_id = doc_engine.add_document_element(slug, "image") + + doc_engine.move_document_element(slug, third_id, None, 0) + + elements = doc_engine.list_document_elements(slug) + 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 = _new_support(tmp_support_slug_cleanup) + row_id = doc_engine.add_document_element(slug, "row") + lone_titre_id = doc_engine.add_document_element(slug, "titre") + + 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 = _new_support(tmp_support_slug_cleanup) + row_id = doc_engine.add_document_element(slug, "row") + child_id = doc_engine.add_document_element(slug, "titre", 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 = _new_support(tmp_support_slug_cleanup) + doc_engine.delete_document_element(slug, 999) + + +def test_render_document_includes_row_children_and_shape_position(tmp_support_slug_cleanup: Any) -> None: + slug = _new_support(tmp_support_slug_cleanup) + row_id = doc_engine.add_document_element(slug, "row") + doc_engine.add_document_element(slug, "titre", parent_id=row_id) + rect_id = doc_engine.add_document_element(slug, "rectangle") + + html = doc_engine.render_document(doc_engine.list_document_elements(slug)) + + assert 'data-kind="row"' in html + assert 'data-kind="titre"' in html + assert f'data-element-id="{rect_id}"' in html + assert "left:40px" in html + assert "top:40px" in html diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py new file mode 100644 index 00000000..851b99d4 --- /dev/null +++ b/tests/document/test_document_routes.py @@ -0,0 +1,124 @@ +"""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).""" + +from typing import Any + +from flask.testing import FlaskClient + +import auth +import db + + +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"}) + 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: + assert client.post(f"/document/{support}/elements/add", data={"kind": "n-importe-quoi"}).status_code == 400 + + +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"}) + assert resp.status_code == 200 + assert resp.get_json()["kind"] == "row" + + +def test_document_element_update_persists_attributes(client: FlaskClient, support: str) -> None: + add_resp = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}) + 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"}) + assert reread.status_code == 200 + + +def test_document_element_move_and_delete(client: FlaskClient, support: str) -> None: + first_id = client.post(f"/document/{support}/elements/add", data={"kind": "titre"}).get_json()["id"] + second_id = client.post(f"/document/{support}/elements/add", data={"kind": "paragraphe"}).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_publish_persists_published_at(client: FlaskClient, support: str) -> None: + assert db.support_meta(support)["published_at"] is None + resp = client.post(f"/document/{support}/publish") + assert resp.status_code == 200 + published_at = resp.get_json()["published_at"] + assert published_at is not None + assert db.support_meta(support)["published_at"] == published_at + + +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) diff --git a/tests/document/test_support_lifecycle.py b/tests/document/test_support_lifecycle.py new file mode 100644 index 00000000..e9fdfb5c --- /dev/null +++ b/tests/document/test_support_lifecycle.py @@ -0,0 +1,70 @@ +"""Cycle de vie d'un support de formation (db/supports/) — mirroir des +tests de db/games/, mais vérifie en plus l'ISOLATION stricte entre les +deux systèmes : un support ne doit jamais apparaître dans db.list_games() +et un jeu ne doit jamais apparaître dans db.list_supports(), même pour le +même propriétaire.""" + +from typing import Any + +import db + + +def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_cleanup: Any) -> None: + slug = db.create_support("Sécurité incendie", owner_folder="42") + tmp_support_slug_cleanup(slug) + meta = db.support_meta(slug) + assert meta["name"] == "Sécurité incendie" + assert meta["published_at"] is None + + +def test_list_supports_scopes_to_owner_and_excludes_games( + tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any +) -> None: + owner = "43" + support_slug = db.create_support("Onboarding RH", owner_folder=owner) + tmp_support_slug_cleanup(support_slug) + game_slug = db.create_game("Un jeu du même compte", owner_folder=owner) + tmp_game_slug_cleanup(game_slug) + + supports = db.list_supports(owner) + games = db.list_games(owner) + + assert [s["slug"] for s in supports] == [support_slug] + assert [g["slug"] for g in games] == [game_slug] + assert support_slug not in [g["slug"] for g in games] + assert game_slug not in [s["slug"] for s in supports] + + +def test_list_supports_never_sees_another_owners_supports(tmp_support_slug_cleanup: Any) -> None: + slug_a = db.create_support("Support A", owner_folder="44") + tmp_support_slug_cleanup(slug_a) + slug_b = db.create_support("Support B", owner_folder="45") + tmp_support_slug_cleanup(slug_b) + + assert [s["slug"] for s in db.list_supports("44")] == [slug_a] + assert [s["slug"] for s in db.list_supports("45")] == [slug_b] + + +def test_create_support_avoids_name_collision_for_the_same_owner(tmp_support_slug_cleanup: Any) -> None: + owner = "46" + slug_1 = db.create_support("Module 1", owner_folder=owner) + tmp_support_slug_cleanup(slug_1) + slug_2 = db.create_support("Module 1", owner_folder=owner) + tmp_support_slug_cleanup(slug_2) + assert slug_1 != slug_2 + + +def test_mark_support_published_persists_a_timestamp(tmp_support_slug_cleanup: Any) -> None: + slug = db.create_support("À publier", owner_folder="47") + tmp_support_slug_cleanup(slug) + assert db.support_meta(slug)["published_at"] is None + published_at = db.mark_support_published(slug) + assert db.support_meta(slug)["published_at"] == published_at + + +def test_delete_support_removes_it_from_the_listing(tmp_support_slug_cleanup: Any) -> None: + slug = db.create_support("Temporaire", owner_folder="48") + tmp_support_slug_cleanup(slug) + assert [s["slug"] for s in db.list_supports("48")] == [slug] + db.delete_support(slug) + assert db.list_supports("48") == [] diff --git a/tests/test_onboarding.py b/tests/test_onboarding.py index 689ea42b..90f5809f 100644 --- a/tests/test_onboarding.py +++ b/tests/test_onboarding.py @@ -26,7 +26,11 @@ def test_account_without_a_project_is_redirected_to_onboarding(anon_client: Flas resp = anon_client.get("/onboarding") assert resp.status_code == 200 - assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True) + # Aucun des deux slots n'est encore rempli — les deux cartes sont + # proposées (voir docs/plan/PLAN.md, projet_kind de premier niveau). + html = resp.get_data(as_text=True) + assert "Environnement de formation 2D" in html + assert "Support de formation" in html finally: _cleanup_project("needsonboarding@example.com") @@ -90,24 +94,44 @@ def test_onboarding_rejects_removed_types(anon_client: FlaskClient) -> None: # _cleanup_project("removedtype@example.com") -def test_revisiting_onboarding_after_completion_redirects_without_recreating(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre +def test_revisiting_onboarding_after_completing_one_slot_offers_only_the_other(anon_client: FlaskClient) -> None: # noqa: F811 - anon_client est un fixture pytest (voir tests.test_auth), pas une vraie redefinition - pyflakes ne comprend pas l'injection de fixture par parametre + """Les deux slots (project_slug/support_slug) sont indépendants (voir + docs/plan/PLAN.md) : avoir déjà un environnement 2D ne bloque plus + /onboarding, mais n'y propose plus que la carte encore libre — tenter + de recréer un environnement 2D via un POST direct doit rester rejeté.""" _register(anon_client, "revisit@example.com") _confirm_2fa(anon_client) + support_slug = None try: - first = _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg") + _complete_onboarding(anon_client, name="Premier jeu", onboarding_type="rpg") first_slug = not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] - resp = anon_client.get("/onboarding", follow_redirects=False) - assert resp.status_code == 302 - assert resp.headers["Location"] == first.headers["Location"] + resp = anon_client.get("/onboarding") + assert resp.status_code == 200 + html = resp.get_data(as_text=True) + assert "Environnement de formation 2D" not in html + assert "Support de formation" in html again = anon_client.post( "/onboarding", data={"onboarding_type": "rpg", "name": "Second jeu"}, follow_redirects=False ) + assert again.status_code == 200 assert not_none(auth.get_user_by_email("revisit@example.com"))["project_slug"] == first_slug - assert again.headers["Location"] == first.headers["Location"] + + second = anon_client.post( + "/onboarding", data={"onboarding_type": "support", "name": "Un support"}, follow_redirects=False + ) + assert second.status_code == 302 + support_slug = not_none(auth.get_user_by_email("revisit@example.com"))["support_slug"] + assert second.headers["Location"] == f"/document/{support_slug}/edit" + + both_full = anon_client.get("/onboarding", follow_redirects=False) + assert both_full.status_code == 302 + assert both_full.headers["Location"] == "/" finally: _cleanup_project("revisit@example.com") + if support_slug: + db.delete_support(support_slug) def test_admin_is_never_redirected_to_onboarding(client: FlaskClient, game: str) -> None: @@ -132,7 +156,7 @@ def test_admin_can_create_several_games_via_onboarding(client: FlaskClient) -> N # le premier projet créé, la page de choix s'affiche à nouveau. resp = client.get("/onboarding") assert resp.status_code == 200 - assert "Créer un jeu 2D ludique" in resp.get_data(as_text=True) + assert "Environnement de formation 2D" in resp.get_data(as_text=True) resp2 = _complete_onboarding(client, name="Jeu RPG Admin 2", onboarding_type="rpg") assert resp2.status_code == 302