Retire l'export exécutable (.exe) et la partie publique en ligne (/jouer)
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:
co-authored by
Claude Sonnet 5
parent
82d00b3800
commit
e82eb7ce88
@@ -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 == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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é
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user