Retire l'export exécutable (.exe) et la partie publique en ligne (/jouer)
Build and deploy / test-python (push) Successful in 7m5s
Build and deploy / test-js (push) Successful in 58s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped

Décision produit : seul l'export Web/SCORM (LMS) est pertinent — l'export
exécutable Windows autonome (publish/build_package.py, bouton "Publier")
et la partie publique par joueur (/jouer/<slug>, routes/public_play/,
"Publier en ligne") sont jugés redondants et retirés.

Conserve le mécanisme d'état "par joueur" (db/global_vars,
db/rows, per_player) : infrastructure générique déjà utilisée par
Score/Progression et testée indépendamment de toute route publique
(voir tests/test_player_state.py), aucune raison de la retirer.

_STATIC_ITEMS/_copy_characters (copie sélective des sprites CraftPix
réellement utilisés) migrent de build_package.py vers
build_scorm_package.py, seul appelant restant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-02 10:59:28 +02:00
co-authored by Claude Sonnet 5
parent 82d00b3800
commit e82eb7ce88
37 changed files with 115 additions and 1106 deletions
-22
View File
@@ -99,25 +99,3 @@ def test_chained_ajouter_ligne_then_modifier_donnee_sets_the_new_row_fields(clie
assert row["objet"] == "épée"
def test_new_row_is_scoped_to_the_acting_player(client, game):
"""Un objet per_player (par défaut) : la ligne créée appartient au
joueur qui a agi, pas partagée avec les autres — voir db/rows/
resolve_row_player_key.py."""
from conftest import flask_app
definition_id = _create_inventory_object(client, game)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id, node_type="action", action_type="ajouter_ligne", target_definition_id=definition_id,
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}")
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-add-row")
assert resp.get_json()["ok"] is True
definition = db.get_definition(game, definition_id)
assert db.list_rows(game, definition, player_id="__shared__") == []
bob_rows = db.list_rows(game, definition, player_id="quelquun-dautre")
assert bob_rows == []
+5 -4
View File
@@ -140,13 +140,14 @@ def test_list_used_forge_characters_scans_elements_and_scene_objects(client, tmp
assert used == {"animal-bunny-01"}
def test_build_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_publish_animals"))
def test_build_scorm_package_only_copies_used_animal_character_folders(client, tmp_game_slug_cleanup, tmp_path):
slug = tmp_game_slug_cleanup(_create_jeu2d_game_as(client, "pytest_scorm_animals"))
screen_id = screens.create_screen(slug, "Scène 1")
screens.add_scene_object(slug, screen_id, forge_character="animal-bunny-01")
from publish import build_package
zip_path, _name = build_package.build_game_zip(slug)
with client.application.test_request_context():
from publish import build_scorm_package
zip_path, _name = build_scorm_package.build_scorm_zip(slug)
try:
import zipfile
with zipfile.ZipFile(zip_path) as zf:
-116
View File
@@ -1,116 +0,0 @@
"""Tests de la route publique /jouer/<slug> (routes/public_play/) — état
par joueur, Phase 1. Contrairement aux autres tests, ceux-ci utilisent
DIRECTEMENT flask_app.test_client() (pas la fixture `client`) pour de
vrais visiteurs anonymes, jamais connectés — le but est justement de
vérifier que core/auth_guard.py laisse passer ces routes sans session."""
import json
import re
import db
from conftest import flask_app
def _create_screen(client, slug, name="Accueil"):
resp = client.post(f"/game/{slug}/screens/new", data={"name": name}, follow_redirects=False)
return int(re.search(r"/screens/(\d+)/edit", resp.headers["Location"]).group(1))
def _add_flow_node(client, slug, screen_id, **fields):
resp = client.post(
f"/game/{slug}/screens/{screen_id}/flow/nodes/add",
data=json.dumps(fields),
content_type="application/json",
)
assert resp.status_code == 200
return resp.get_json()["id"]
def test_public_route_404_until_explicitly_published(client, game):
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
client.post(f"/game/{game}/toggle-public-played", data={"value": "0"})
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 404
def test_public_route_works_without_any_login_session(client, game):
"""La garde globale (core/auth_guard.py) ne doit PAS exiger de session
pour cette route — c'est tout le sens de "publique"."""
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
with anon.session_transaction() as sess:
assert "user_id" not in sess
resp = anon.get(f"/jouer/{game}")
assert resp.status_code == 200
assert b"CSRF_TOKEN" in resp.data or b"gameData" in resp.data
def test_public_route_sets_player_id_cookie(client, game):
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
anon = flask_app.test_client()
resp = anon.get(f"/jouer/{game}")
assert "forge_player_id" in resp.headers.get("Set-Cookie", "")
def test_two_anonymous_players_get_independent_variable_state(client, game):
"""Bout en bout, via HTTP : deux visiteurs anonymes (deux clients de
test SÉPARÉS, donc deux cookies forge_player_id différents) sur le
même jeu publié -> chacun sa propre valeur pour une variable
per_player, modifiée via l'action "Modifier une variable" (le vrai
chemin d'exécution du mode jouable, routes/public_play/
flow_node_run_variable_public.py)."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}") # pose le cookie
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
bob = flask_app.test_client()
bob.get(f"/jouer/{game}")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
assert resp.get_json()["ok"] is True
assert db.get_global_variable(game, "score", player_id="__shared__")["value"] == "0"
alice_payload = alice.get(f"/jouer/{game}/runtime-payload").get_json()
bob_payload = bob.get(f"/jouer/{game}/runtime-payload").get_json()
assert alice_payload["variables"]["score"]["value"] == "10"
assert bob_payload["variables"]["score"]["value"] == "20"
def test_creator_preview_unaffected_by_public_players(client, game):
"""L'aperçu créateur (/game/<slug>/play, connecté) doit continuer de
voir/modifier PLAYER_SHARED, jamais l'état d'un joueur public."""
db.create_global_variable(game, "score", "nombre_entier", "0", per_player=True)
screen_id = _create_screen(client, game)
node_id = _add_flow_node(
client, game, screen_id,
node_type="action", action_type="modifier_variable",
target_variable="score", data_operation="incrementer", data_value="10",
)
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
alice = flask_app.test_client()
alice.get(f"/jouer/{game}")
alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-variable")
resp = client.get(f"/game/{game}/play")
assert resp.status_code == 200
creator_payload = client.get(f"/game/{game}/runtime-payload").get_json()
assert creator_payload["variables"]["score"]["value"] == "0"
-92
View File
@@ -1,92 +0,0 @@
"""Publier un jeu en exécutable Windows autonome (bouton "Publier", voir
routes/publish/publish_game.py et publish/build_package.py). Le vrai
téléchargement du Python portable + Flask (publish/vendor_runtime.py)
n'a JAMAIS lieu ici : `ensure_vendor_ready` est monkeypatché vers un
dossier factice minuscule — ces tests vérifient l'ASSEMBLAGE du zip
(fichiers présents, structure), jamais un vrai lancement du serveur
embarqué (nécessite un environnement Windows réel, voir le plan)."""
import os
import zipfile
import pytest
import publish.build_package as build_package
from tests.test_auth import anon_client # noqa: F401
@pytest.fixture
def fake_vendor(tmp_path, monkeypatch):
"""Un python-embed/pylibs factice, juste assez pour que l'assemblage
du zip (copie de fichiers) fonctionne sans jamais toucher au réseau."""
python_dir = tmp_path / "python-embed-amd64"
python_dir.mkdir()
(python_dir / "python.exe").write_bytes(b"fake-exe")
pylibs_dir = tmp_path / "pylibs"
pylibs_dir.mkdir()
(pylibs_dir / "flask").mkdir()
(pylibs_dir / "flask" / "__init__.py").write_text("# fake", encoding="utf-8")
def _fake_ensure_vendor_ready():
return str(python_dir), str(pylibs_dir)
monkeypatch.setattr(build_package.vendor_runtime, "ensure_vendor_ready", _fake_ensure_vendor_ready)
return python_dir, pylibs_dir
def test_build_game_zip_contains_the_expected_files(fake_vendor, game):
zip_path, game_name = build_package.build_game_zip(game)
try:
assert game_name # nom du jeu (pytest_test_game par défaut, voir conftest.py)
with zipfile.ZipFile(zip_path) as zf:
names = set(zf.namelist())
for expected in [
"run.bat", "player_app.py",
f"projects/{game}/game.db",
"screens/__init__.py", "db/__init__.py", "filters/__init__.py",
"core/__init__.py", "core/flask_app.py", "core/jinja_filters.py",
"templates/play.html",
"static/style.css", "static/csrf_fetch.js",
"python-embed/python.exe", "python-embed/pylibs/flask/__init__.py",
]:
assert expected in names, f"{expected} manquant du zip"
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_build_game_zip_bakes_the_slug_into_player_app(fake_vendor, game):
zip_path, _ = build_package.build_game_zip(game)
try:
with zipfile.ZipFile(zip_path) as zf:
player_app_src = zf.read("player_app.py").decode("utf-8")
assert f'SLUG = "{game}"' in player_app_src
assert "_SLUG_PLACEHOLDER" not in player_app_src
finally:
if os.path.isfile(zip_path):
os.remove(zip_path)
def test_publish_route_returns_a_zip_attachment(fake_vendor, client, game):
resp = client.post(f"/game/{game}/publish")
assert resp.status_code == 200
assert resp.headers["Content-Type"] == "application/zip"
assert "attachment" in resp.headers["Content-Disposition"]
assert resp.data[:2] == b"PK" # signature d'un fichier zip
def test_publish_route_is_isolated_like_other_game_routes(fake_vendor, anon_client):
"""Réutilise la garde d'accès existante (core/auth_guard.py) sans
code supplémentaire — un compte non-admin ne doit pas pouvoir publier
le projet d'un autre (même vérification que /game/<slug> lui-même,
voir tests/test_auth.py::test_non_admin_user_is_isolated_to_their_own_project)."""
from tests.test_auth import _register, _confirm_2fa, _complete_onboarding, _cleanup_project
_register(anon_client, "publishisolation@example.com")
_confirm_2fa(anon_client)
_complete_onboarding(anon_client)
try:
resp = anon_client.post("/game/un-projet-qui-nest-pas-le-sien/publish")
assert resp.status_code == 403
finally:
_cleanup_project("publishisolation@example.com")
+12 -37
View File
@@ -7,8 +7,6 @@ import json
import db
import screens
from conftest import flask_app
def test_get_score_defaults_before_any_write(game):
assert db.get_score(game) == {"player_id": db.PLAYER_SHARED, "score": 0, "status": "non_commence"}
@@ -104,43 +102,20 @@ def test_game_scoring_view_route_reads_player_score(client, game):
}
def test_two_anonymous_players_get_independent_scores(client, game):
"""Bout en bout via HTTP, même patron que
test_public_play.py::test_two_anonymous_players_get_independent_variable_state
— le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé."""
screen_id = screens.create_screen(game, "Accueil")
node_id = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "modifier_score", "data_operation": "incrementer", "data_value": "10"}),
content_type="application/json",
).get_json()["id"]
status_node_id = client.post(
f"/game/{game}/screens/{screen_id}/flow/nodes/add",
data=json.dumps({"node_type": "action", "action_type": "definir_statut_partie", "data_value": "reussi"}),
content_type="application/json",
).get_json()["id"]
client.post(f"/game/{game}/toggle-public-played", data={"value": "1"})
def test_two_players_get_independent_scores(game):
"""Le score est TOUJOURS par joueur (voir db/scoring/), jamais partagé
— même garantie que db/global_vars (voir tests/test_player_state.py),
vérifiée ici directement au niveau db/ (routes publiques retirées)."""
db.set_score_value(game, 10, player_id="alice")
db.set_status(game, "reussi", player_id="alice")
db.set_score_value(game, 20, player_id="bob")
alice = flask_app.test_client()
alice_landing = alice.get(f"/jouer/{game}")
resp = alice.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
assert resp.get_json()["ok"] is True
alice.post(f"/jouer/{game}/flow/nodes/{status_node_id}/run-status")
bob = flask_app.test_client()
bob_landing = bob.get(f"/jouer/{game}")
bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
bob.post(f"/jouer/{game}/flow/nodes/{node_id}/run-score")
alice_player_id = alice_landing.headers["Set-Cookie"].split("forge_player_id=", 1)[1].split(";", 1)[0]
bob_player_id = bob_landing.headers["Set-Cookie"].split("forge_player_id=", 1)[1].split(";", 1)[0]
assert db.get_score(game, alice_player_id) == {
"player_id": alice_player_id, "score": 10, "status": "reussi",
"updated_at": db.get_score(game, alice_player_id)["updated_at"],
assert db.get_score(game, "alice") == {
"player_id": "alice", "score": 10, "status": "reussi",
"updated_at": db.get_score(game, "alice")["updated_at"],
}
assert db.get_score(game, bob_player_id)["score"] == 20
assert db.get_score(game, bob_player_id)["status"] == "non_commence"
assert db.get_score(game, "bob")["score"] == 20
assert db.get_score(game, "bob")["status"] == "non_commence"
assert db.get_score(game)["score"] == 0 # aperçu créateur (PLAYER_SHARED) inchangé