From bb84b7b377612c37be822e98146a6bdd9e17db53 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 30 Aug 2026 15:00:23 +0200 Subject: [PATCH] =?UTF-8?q?Phase=200=20:=20factorise=20compute=5Fnew=5Fval?= =?UTF-8?q?ue=20+=20ajoute=20pytest/node=20test=20=C3=A0=20la=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Duplication éliminée avant que la Phase 2 (hasard/opérations mathématiques) n'en ajoute 6 de plus aux DEUX fichiers : la chaîne d'opérations quasi identique entre screens/data_actions/ apply_data_action.py (champ d'objet) et apply_variable_action.py (variable globale) est factorisée dans un nouveau compute_operation.py::compute_new_value(operation, current, raw_value, is_decimal), réutilisé par les deux. Nouveau tests/test_compute_operation.py verrouille le comportement des 7 opérations existantes (dont les cas limites : valeur invalide, type décimal vs entier, opération inconnue) avant d'en ajouter d'autres. 2. .gitea/workflows/deploy.yml déployait en prod à chaque push sur main sans jamais exécuter la suite de tests — rien ne bloquait techniquement un commit cassé. Nouveau job "test" (pytest + node:test sur la logique pure de static/js/play/, via des conteneurs officiels plutôt que des actions du marketplace, cohérent avec le choix déjà fait dans ce fichier) tourne sur CHAQUE push (main ET dev, utile pour ce dépôt qui travaille sur dev) ; "build-and-push"/"deploy" gagnent un "needs: test" et restent réservés à main (filtre sur gitea.ref) — un push sur dev ne redéploie jamais la prod, seulement les tests. Vérifié : 223 tests passent (8 nouveaux), YAML validé. Co-Authored-By: Claude Sonnet 5 --- .gitea/workflows/deploy.yml | 35 +++++++++++- screens/data_actions/apply_data_action.py | 28 ++-------- screens/data_actions/apply_variable_action.py | 28 ++-------- screens/data_actions/compute_operation.py | 35 ++++++++++++ tests/test_compute_operation.py | 54 +++++++++++++++++++ 5 files changed, 133 insertions(+), 47 deletions(-) create mode 100644 screens/data_actions/compute_operation.py create mode 100644 tests/test_compute_operation.py diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a9adeaba..8eeb6b7b 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -2,7 +2,14 @@ name: Build and deploy on: push: - branches: [main] + branches: [main, dev] + +# Le job "test" tourne sur CHAQUE push (main et dev) : jusqu'ici aucune +# étape de CI n'exécutait la suite de tests, rien n'empêchait un commit +# cassé d'atteindre la production (voir l'audit qualité de la Phase 0 du +# plan). "build-and-push"/"deploy", eux, restent réservés à main (via le +# filtre "if" sur gitea.ref) — un push sur dev ne doit jamais redéployer +# la prod, seulement faire tourner les tests. # Secrets à configurer dans Gitea (Paramètres du dépôt > Actions > Secrets) : # REGISTRY_HOST adresse du registre d'images (ex: gitea.exemple.com) @@ -19,7 +26,32 @@ on: # github.com / du miroir gitea.com depuis le runner self-hosted. jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Conteneurs officiels plutôt que actions/setup-python|setup-node du + # marketplace — même logique que le reste de ce fichier (éviter une + # dépendance à la disponibilité de github.com/du miroir gitea.com + # depuis le runner self-hosted), et le runner n'a pas besoin + # d'installer quoi que ce soit d'autre que Docker, déjà requis pour + # build-and-push. + - name: Tests Python (pytest) + run: | + docker run --rm -v "$PWD":/app -w /app python:3.13-slim sh -c " + pip install --quiet -r requirements-dev.txt && + python -m pytest tests/ -q + " + + - name: Tests JS (node:test — logique pure de static/js/play/, voir le plan de modularisation) + run: | + docker run --rm -v "$PWD":/app -w /app node:20-slim \ + node --test static/js/play/__tests__/*.test.js + build-and-push: + needs: test + if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -41,6 +73,7 @@ jobs: deploy: needs: build-and-push + if: gitea.ref == 'refs/heads/main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/screens/data_actions/apply_data_action.py b/screens/data_actions/apply_data_action.py index 8f32ae67..0d37ecfb 100644 --- a/screens/data_actions/apply_data_action.py +++ b/screens/data_actions/apply_data_action.py @@ -1,5 +1,7 @@ import db +from .compute_operation import compute_new_value + def apply_data_action(slug, action): """Exécute au moment du clic (mode jouable) une action "modifier_donnee" : @@ -28,29 +30,9 @@ def apply_data_action(slug, action): col = col + "_id" current = row.get(col) - if operation == "definir_texte": - new_value = raw_value or "" - elif operation == "definir_nombre": - try: - new_value = float(raw_value) if field_def["type"] == "nombre_decimal" else int(float(raw_value)) - except (TypeError, ValueError): - new_value = 0 - elif operation == "definir_bool_vrai": - new_value = 1 - elif operation == "definir_bool_faux": - new_value = 0 - elif operation == "basculer_bool": - new_value = 0 if current else 1 - elif operation in ("incrementer", "decrementer"): - try: - amount = float(raw_value) if raw_value not in (None, "") else 0 - except (TypeError, ValueError): - amount = 0 - if operation == "decrementer": - amount = -amount - result = (current or 0) + amount - new_value = result if field_def["type"] == "nombre_decimal" else int(result) - else: + try: + new_value = compute_new_value(operation, current, raw_value, field_def["type"] == "nombre_decimal") + except ValueError: return False new_value = _clamp_to_field_bounds(new_value, field_def) diff --git a/screens/data_actions/apply_variable_action.py b/screens/data_actions/apply_variable_action.py index f3d37704..0342ddbd 100644 --- a/screens/data_actions/apply_variable_action.py +++ b/screens/data_actions/apply_variable_action.py @@ -1,5 +1,7 @@ import db +from .compute_operation import compute_new_value + def apply_variable_action(slug, action): """Exécute au moment du clic (mode jouable) une action @@ -25,29 +27,9 @@ def apply_variable_action(slug, action): elif var_type == "booleen": current = 1 if str(current).strip().lower() in ("1", "true", "vrai") else 0 - if operation == "definir_texte": - new_value = raw_value or "" - elif operation == "definir_nombre": - try: - new_value = float(raw_value) if var_type == "nombre_decimal" else int(float(raw_value)) - except (TypeError, ValueError): - new_value = 0 - elif operation == "definir_bool_vrai": - new_value = 1 - elif operation == "definir_bool_faux": - new_value = 0 - elif operation == "basculer_bool": - new_value = 0 if current else 1 - elif operation in ("incrementer", "decrementer"): - try: - amount = float(raw_value) if raw_value not in (None, "") else 0 - except (TypeError, ValueError): - amount = 0 - if operation == "decrementer": - amount = -amount - result = (current or 0) + amount - new_value = result if var_type == "nombre_decimal" else int(result) - else: + try: + new_value = compute_new_value(operation, current, raw_value, var_type == "nombre_decimal") + except ValueError: return False return db.update_global_variable_value(slug, var_name, new_value) diff --git a/screens/data_actions/compute_operation.py b/screens/data_actions/compute_operation.py new file mode 100644 index 00000000..6f1ca773 --- /dev/null +++ b/screens/data_actions/compute_operation.py @@ -0,0 +1,35 @@ +def compute_new_value(operation, current, raw_value, is_decimal): + """Calcule la nouvelle valeur d'un champ d'objet ou d'une variable + globale pour une opération de DATA_OPERATIONS (voir + screens/labels/data_operations.py) — logique partagée par + apply_data_action.py (champ d'objet) et apply_variable_action.py + (variable globale), qui ne diffèrent que par l'origine de `current` + et la façon dont le résultat est persisté. `is_decimal` : True pour + un champ/une variable de type "nombre_decimal" (résultat numérique + gardé en flottant), False sinon (résultat toujours entier). + + Lève ValueError si `operation` n'est pas reconnue — à l'appelant de + la traduire en son propre échec (ex. `return False`).""" + if operation == "definir_texte": + return raw_value or "" + if operation == "definir_nombre": + try: + return float(raw_value) if is_decimal else int(float(raw_value)) + except (TypeError, ValueError): + return 0 + if operation == "definir_bool_vrai": + return 1 + if operation == "definir_bool_faux": + return 0 + if operation == "basculer_bool": + return 0 if current else 1 + if operation in ("incrementer", "decrementer"): + try: + amount = float(raw_value) if raw_value not in (None, "") else 0 + except (TypeError, ValueError): + amount = 0 + if operation == "decrementer": + amount = -amount + result = (current or 0) + amount + return result if is_decimal else int(result) + raise ValueError(f"opération inconnue : {operation!r}") diff --git a/tests/test_compute_operation.py b/tests/test_compute_operation.py new file mode 100644 index 00000000..df60e2c6 --- /dev/null +++ b/tests/test_compute_operation.py @@ -0,0 +1,54 @@ +"""Tests unitaires de compute_new_value (screens/data_actions/compute_operation.py) +— logique partagée par apply_data_action.py (champ d'objet) et +apply_variable_action.py (variable globale), factorisée pour ne plus être +dupliquée dans les deux fichiers avant que la Phase 2 (hasard/opérations +mathématiques) n'y ajoute 6 nouvelles opérations. Verrouille le +comportement des 7 opérations existantes.""" +import pytest + +from screens.data_actions.compute_operation import compute_new_value + + +def test_definir_texte(): + assert compute_new_value("definir_texte", "ancien", "nouveau", False) == "nouveau" + assert compute_new_value("definir_texte", "ancien", "", False) == "" + assert compute_new_value("definir_texte", "ancien", None, False) == "" + + +def test_definir_nombre_entier_et_decimal(): + assert compute_new_value("definir_nombre", 0, "5", False) == 5 + assert isinstance(compute_new_value("definir_nombre", 0, "5", False), int) + assert compute_new_value("definir_nombre", 0, "5.7", True) == 5.7 + assert compute_new_value("definir_nombre", 0, "pas un nombre", False) == 0 + + +def test_definir_bool(): + assert compute_new_value("definir_bool_vrai", 0, None, False) == 1 + assert compute_new_value("definir_bool_faux", 1, None, False) == 0 + + +def test_basculer_bool(): + assert compute_new_value("basculer_bool", 0, None, False) == 1 + assert compute_new_value("basculer_bool", 1, None, False) == 0 + assert compute_new_value("basculer_bool", None, None, False) == 1 + + +def test_incrementer_decrementer_entier(): + assert compute_new_value("incrementer", 10, "5", False) == 15 + assert compute_new_value("decrementer", 10, "5", False) == 5 + assert compute_new_value("incrementer", None, "5", False) == 5 + + +def test_incrementer_decimal_garde_les_decimales(): + result = compute_new_value("incrementer", 10.0, "0.5", True) + assert result == 10.5 + assert isinstance(result, float) + + +def test_incrementer_valeur_invalide_ne_change_rien(): + assert compute_new_value("incrementer", 10, "pas un nombre", False) == 10 + + +def test_operation_inconnue_leve_value_error(): + with pytest.raises(ValueError): + compute_new_value("multiplier", 10, "2", False)