diff --git a/screens/data_actions/compute_operation.py b/screens/data_actions/compute_operation.py index 6f1ca773..eb0822c2 100644 --- a/screens/data_actions/compute_operation.py +++ b/screens/data_actions/compute_operation.py @@ -1,3 +1,6 @@ +import random + + 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 @@ -32,4 +35,47 @@ def compute_new_value(operation, current, raw_value, is_decimal): amount = -amount result = (current or 0) + amount return result if is_decimal else int(result) + # Phase 2 — hasard + opérations mathématiques. "multiplier"/"diviser"/ + # "modulo" reprennent le même patron qu'incrementer/decrementer (un + # seul opérande numérique dans raw_value) ; "minimum"/"maximum" + # bornent la valeur ACTUELLE (utile pour une variable globale, qui n'a + # pas de min_value/max_value comme un champ d'objet, voir + # _clamp_to_field_bounds dans apply_data_action.py) ; "alea" est la + # seule opération à deux opérandes ("min,max" dans raw_value plutôt + # qu'une nouvelle colonne de nœud — voir updateDataValuePlaceholder() + # dans static/js/screen_edit/flow-editor.js). + if operation == "multiplier": + try: + factor = float(raw_value) if raw_value not in (None, "") else 1 + except (TypeError, ValueError): + factor = 1 + result = (current or 0) * factor + return result if is_decimal else int(result) + if operation in ("diviser", "modulo"): + try: + divisor = float(raw_value) if raw_value not in (None, "") else 0 + except (TypeError, ValueError): + divisor = 0 + if divisor == 0: + # Garde-fou division par zéro : valeur inchangée plutôt qu'une + # ZeroDivisionError qui interromprait tout le graphe de logique. + return current + result = (current or 0) / divisor if operation == "diviser" else (current or 0) % divisor + return result if is_decimal else int(result) + if operation in ("minimum", "maximum"): + try: + bound = float(raw_value) + except (TypeError, ValueError): + return current + result = max(current or 0, bound) if operation == "minimum" else min(current or 0, bound) + return result if is_decimal else int(result) + if operation == "alea": + parts = (raw_value or "").split(",") + try: + lo, hi = float(parts[0]), float(parts[1]) + except (IndexError, TypeError, ValueError): + lo, hi = 0, 0 + if lo > hi: + lo, hi = hi, lo + return random.uniform(lo, hi) if is_decimal else random.randint(int(lo), int(hi)) raise ValueError(f"opération inconnue : {operation!r}") diff --git a/screens/labels/data_operations.py b/screens/labels/data_operations.py index 5159fd92..922bb940 100644 --- a/screens/labels/data_operations.py +++ b/screens/labels/data_operations.py @@ -8,5 +8,13 @@ DATA_OPERATIONS = [ ("basculer_bool", "Basculer Vrai / Faux"), ("incrementer", "Augmenter de..."), ("decrementer", "Diminuer de..."), + # Phase 2 (hasard + opérations mathématiques) — voir + # screens/data_actions/compute_operation.py pour le calcul de chacune. + ("multiplier", "Multiplier par..."), + ("diviser", "Diviser par..."), + ("modulo", "Reste de la division par... (modulo)"), + ("minimum", "Ne jamais descendre sous..."), + ("maximum", "Ne jamais dépasser..."), + ("alea", "Tirer un nombre aléatoire entre... (min,max)"), ] DATA_OPERATION_LABELS = dict(DATA_OPERATIONS) diff --git a/static/js/screen_edit/flow-editor.js b/static/js/screen_edit/flow-editor.js index 245f64a8..acd12e4e 100644 --- a/static/js/screen_edit/flow-editor.js +++ b/static/js/screen_edit/flow-editor.js @@ -457,6 +457,19 @@ function toggleFlowActionFields() { document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block'; if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue(); if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action'); + updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value'); + updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value'); +} + +// "alea" (Phase 2 — hasard) attend "min,max" dans data_value (voir +// screens/data_actions/compute_operation.py) plutôt qu'un seul nombre +// comme les autres opérations — indice visuel dans le champ plutôt que +// d'ajouter un champ dédié (aucun changement de schéma nécessaire). +function updateDataValuePlaceholder(operationSelectId, valueInputId) { + var opSel = document.getElementById(operationSelectId); + var valueInp = document.getElementById(valueInputId); + if (!opSel || !valueInp) return; + valueInp.placeholder = opSel.value === 'alea' ? 'min,max (ex. 1,6)' : 'Valeur / montant'; } function toggleFlowElementPropertyValue() { diff --git a/templates/screen_edit.html b/templates/screen_edit.html index c5a15e26..28b9fbe2 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -419,7 +419,7 @@ - {% for key, label in data_operations %} {% endfor %} @@ -437,7 +437,7 @@ {% endfor %} - {% for key, label in data_operations %} {% endfor %} diff --git a/tests/test_compute_operation.py b/tests/test_compute_operation.py index df60e2c6..7e6a8b2f 100644 --- a/tests/test_compute_operation.py +++ b/tests/test_compute_operation.py @@ -1,9 +1,9 @@ """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.""" +dupliquée dans les deux fichiers. Verrouille le comportement des 7 +opérations historiques ET des 6 opérations ajoutées en Phase 2 (hasard + +opérations mathématiques).""" import pytest from screens.data_actions.compute_operation import compute_new_value @@ -51,4 +51,66 @@ def test_incrementer_valeur_invalide_ne_change_rien(): def test_operation_inconnue_leve_value_error(): with pytest.raises(ValueError): - compute_new_value("multiplier", 10, "2", False) + compute_new_value("teleporter", 10, "2", False) + + +# ---------- Phase 2 — hasard + opérations mathématiques ---------- + +def test_multiplier(): + assert compute_new_value("multiplier", 10, "3", False) == 30 + assert compute_new_value("multiplier", 4.0, "2.5", True) == 10.0 + + +def test_diviser(): + assert compute_new_value("diviser", 10, "2", False) == 5 + assert compute_new_value("diviser", 5.0, "2", True) == 2.5 + + +def test_diviser_par_zero_ne_change_rien(): + assert compute_new_value("diviser", 10, "0", False) == 10 + + +def test_modulo(): + assert compute_new_value("modulo", 10, "3", False) == 1 + assert compute_new_value("modulo", 10, "0", False) == 10 # garde-fou division par zéro + + +def test_minimum_releve_la_valeur_si_en_dessous(): + assert compute_new_value("minimum", 3, "10", False) == 10 + assert compute_new_value("minimum", 15, "10", False) == 15 # déjà au-dessus : inchangé + + +def test_maximum_plafonne_la_valeur_si_au_dessus(): + assert compute_new_value("maximum", 15, "10", False) == 10 + assert compute_new_value("maximum", 3, "10", False) == 3 # déjà en dessous : inchangé + + +def test_minimum_maximum_valeur_invalide_ne_change_rien(): + assert compute_new_value("minimum", 5, "pas un nombre", False) == 5 + assert compute_new_value("maximum", 5, "", False) == 5 + + +def test_alea_entier_dans_les_bornes(): + for _ in range(50): + result = compute_new_value("alea", 0, "1,6", False) + assert isinstance(result, int) + assert 1 <= result <= 6 + + +def test_alea_decimal_dans_les_bornes(): + for _ in range(50): + result = compute_new_value("alea", 0, "1.5,2.5", True) + assert isinstance(result, float) + assert 1.5 <= result <= 2.5 + + +def test_alea_bornes_inversees_sont_remises_dans_lordre(): + for _ in range(50): + result = compute_new_value("alea", 0, "6,1", False) + assert 1 <= result <= 6 + + +def test_alea_valeur_invalide_retombe_sur_zero(): + assert compute_new_value("alea", 0, "pas un nombre", False) == 0 + assert compute_new_value("alea", 0, "", False) == 0 + assert compute_new_value("alea", 0, "5", False) == 0 # une seule borne : incomplet