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)