From d6dbeb4a9cf7be80262876abda951ab5fee856b1 Mon Sep 17 00:00:00 2001 From: Georg Plaz Date: Thu, 12 Jun 2025 02:23:11 +0200 Subject: [PATCH] Implement clamp method --- src/spellbind/float_values.py | 23 +++++ src/spellbind/int_values.py | 19 +++- src/spellbind/values.py | 12 +++ .../test_float_values/test_float_values.py | 84 ++++++++++++++++++ .../test_int_values/test_int_values.py | 86 ++++++++++++++++++- 5 files changed, 220 insertions(+), 4 deletions(-) diff --git a/src/spellbind/float_values.py b/src/spellbind/float_values.py index 42935af..0066192 100644 --- a/src/spellbind/float_values.py +++ b/src/spellbind/float_values.py @@ -98,6 +98,9 @@ def __neg__(self) -> FloatValue: def __pos__(self) -> Self: return self + def clamp(self, min_value: FloatLike, max_value: FloatLike) -> FloatValue: + return ClampFloatValue(self, min_value, max_value) + class MappedFloatValue(Generic[_S], DerivedValue[_S, float], FloatValue): def __init__(self, value: Value[_S], transform: Callable[[_S], float]) -> None: @@ -186,6 +189,18 @@ def transform_two(self, left: float, right: float) -> _U: raise NotImplementedError +class CombinedThreeFloatValues(CombinedFloatValues[_U], Generic[_U], ABC): + def __init__(self, left: FloatLike, middle: FloatLike, right: FloatLike): + super().__init__(left, middle, right) + + def transform(self, values: Sequence[float]) -> _U: + return self.transform_three(values[0], values[1], values[2]) + + @abstractmethod + def transform_three(self, left: float, middle: float, right: float) -> _U: + raise NotImplementedError + + class AddFloatValues(CombinedFloatValues[float], FloatValue): def transform(self, values: Sequence[float]) -> float: return sum(values) @@ -244,3 +259,11 @@ def __init__(self, left: FloatLike, right: FloatLike, op: Callable[[float, float def transform_two(self, left: float, right: float) -> bool: return self._op(left, right) + + +class ClampFloatValue(CombinedThreeFloatValues[float], FloatValue): + def __init__(self, value: FloatLike, min_value: FloatLike, max_value: FloatLike) -> None: + super().__init__(value, min_value, max_value) + + def transform_three(self, value: float, min_value: float, max_value: float) -> float: + return max(min_value, min(max_value, value)) diff --git a/src/spellbind/int_values.py b/src/spellbind/int_values.py index 9565c58..8c7be2c 100644 --- a/src/spellbind/int_values.py +++ b/src/spellbind/int_values.py @@ -1,15 +1,17 @@ from __future__ import annotations -from typing_extensions import Self, TypeVar import math import operator from abc import ABC from typing import overload, Generic, Callable +from typing_extensions import Self, TypeVar + +from spellbind.bool_values import BoolValue from spellbind.float_values import FloatValue, MultiplyFloatValues, DivideValues, SubtractFloatValues, \ AddFloatValues, CompareNumbersValues -from spellbind.values import Value, CombinedMixedValues, SimpleVariable, CombinedTwoValues, DerivedValue, Constant -from spellbind.bool_values import BoolValue +from spellbind.values import Value, CombinedMixedValues, SimpleVariable, CombinedTwoValues, DerivedValue, Constant, \ + CombinedThreeValues IntLike = int | Value[int] FloatLike = IntLike | float | FloatValue @@ -130,6 +132,9 @@ def __neg__(self) -> IntValue: def __pos__(self) -> Self: return self + def clamp(self, min_value: IntLike, max_value: IntLike) -> IntValue: + return ClampIntValue(self, min_value, max_value) + class MappedIntValue(Generic[_S], DerivedValue[_S, int], IntValue): def __init__(self, value: Value[_S], transform: Callable[[_S], int]) -> None: @@ -219,3 +224,11 @@ def transform(self, value: float) -> int: class RoundFloatToIntValue(DerivedValue[float, int], IntValue): def transform(self, value: float) -> int: return round(value) + + +class ClampIntValue(CombinedThreeValues[int, int], IntValue): + def __init__(self, value: IntLike, min_value: IntLike, max_value: IntLike) -> None: + super().__init__(value, min_value, max_value) + + def transform_three(self, value: int, min_value: int, max_value: int) -> int: + return max(min_value, min(max_value, value)) diff --git a/src/spellbind/values.py b/src/spellbind/values.py index 60c295f..c817fc1 100644 --- a/src/spellbind/values.py +++ b/src/spellbind/values.py @@ -323,3 +323,15 @@ def transform(self, *args: _S) -> _T: @property def value(self) -> _T: return self._value + + +class CombinedThreeValues(CombinedMixedValues[_S, _T], Generic[_S, _T], ABC): + def __init__(self, left: Value[_S] | _S, middle: Value[_S] | _S, right: Value[_S] | _S): + super().__init__(left, middle, right) + + def transform(self, *values: _S) -> _T: + return self.transform_three(values[0], values[1], values[2]) + + @abstractmethod + def transform_three(self, left: _S, middle: _S, right: _S) -> _T: + raise NotImplementedError diff --git a/tests/test_values/test_float_values/test_float_values.py b/tests/test_values/test_float_values/test_float_values.py index 6e8b9fb..9dd5d6a 100644 --- a/tests/test_values/test_float_values/test_float_values.py +++ b/tests/test_values/test_float_values/test_float_values.py @@ -76,3 +76,87 @@ def test_add_int_values_garbage_collected(): v1.value = 4.5 # trigger removal of weak references assert len(v0._on_change._subscriptions) == 0 assert len(v1._on_change._subscriptions) == 0 + + +def test_clamp_float_values_in_range(): + value = FloatVariable(15.5) + min_val = FloatVariable(10.0) + max_val = FloatVariable(20.0) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 15.5 + + +def test_clamp_float_values_below_min(): + value = FloatVariable(5.2) + min_val = FloatVariable(10.0) + max_val = FloatVariable(20.0) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 10.0 + + +def test_clamp_float_values_above_max(): + value = FloatVariable(25.8) + min_val = FloatVariable(10.0) + max_val = FloatVariable(20.0) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 20.0 + + +def test_clamp_float_values_with_literals_in_range(): + value = FloatVariable(15.5) + + clamped = value.clamp(10.0, 20.0) + assert clamped.value == 15.5 + + +def test_clamp_float_values_with_literals_below_min(): + value = FloatVariable(5.2) + + clamped = value.clamp(10.0, 20.0) + assert clamped.value == 10.0 + + +def test_clamp_float_values_with_literals_above_max(): + value = FloatVariable(25.8) + + clamped = value.clamp(10.0, 20.0) + assert clamped.value == 20.0 + + +def test_clamp_float_values_reactive_value_changes(): + value = FloatVariable(15.5) + min_val = FloatVariable(10.0) + max_val = FloatVariable(20.0) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 15.5 + + value.value = 5.2 + assert clamped.value == 10.0 + + value.value = 25.8 + assert clamped.value == 20.0 + + value.value = 12.3 + assert clamped.value == 12.3 + + +def test_clamp_float_values_reactive_bounds_changes(): + value = FloatVariable(15.5) + min_val = FloatVariable(10.0) + max_val = FloatVariable(20.0) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 15.5 + + min_val.value = 18.0 + assert clamped.value == 18.0 + + min_val.value = 11.0 + assert clamped.value == 15.5 + + max_val.value = 12.0 + assert clamped.value == 12.0 diff --git a/tests/test_values/test_int_values/test_int_values.py b/tests/test_values/test_int_values/test_int_values.py index c7579b2..8113df2 100644 --- a/tests/test_values/test_int_values/test_int_values.py +++ b/tests/test_values/test_int_values/test_int_values.py @@ -1,4 +1,4 @@ -from spellbind.int_values import IntConstant, MaxIntValues, MinIntValues +from spellbind.int_values import IntConstant, MaxIntValues, MinIntValues, IntVariable from spellbind.values import SimpleVariable @@ -49,3 +49,87 @@ def test_min_int_values_with_literals(): a.value = 5 assert min_val.value == 5 + + +def test_clamp_int_values_in_range(): + value = IntVariable(15) + min_val = IntVariable(10) + max_val = IntVariable(20) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 15 + + +def test_clamp_int_values_below_min(): + value = IntVariable(5) + min_val = IntVariable(10) + max_val = IntVariable(20) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 10 + + +def test_clamp_int_values_above_max(): + value = IntVariable(25) + min_val = IntVariable(10) + max_val = IntVariable(20) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 20 + + +def test_clamp_int_values_with_literals_in_range(): + value = IntVariable(15) + + clamped = value.clamp(10, 20) + assert clamped.value == 15 + + +def test_clamp_int_values_with_literals_below_min(): + value = IntVariable(5) + + clamped = value.clamp(10, 20) + assert clamped.value == 10 + + +def test_clamp_int_values_with_literals_above_max(): + value = IntVariable(25) + + clamped = value.clamp(10, 20) + assert clamped.value == 20 + + +def test_clamp_int_values_reactive_value_changes(): + value = IntVariable(15) + min_val = IntVariable(10) + max_val = IntVariable(20) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 15 + + value.value = 5 + assert clamped.value == 10 + + value.value = 25 + assert clamped.value == 20 + + value.value = 12 + assert clamped.value == 12 + + +def test_clamp_int_values_reactive_bounds_changes(): + value = IntVariable(15) + min_val = IntVariable(10) + max_val = IntVariable(20) + + clamped = value.clamp(min_val, max_val) + assert clamped.value == 15 + + min_val.value = 18 + assert clamped.value == 18 + + min_val.value = 11 + assert clamped.value == 15 + + max_val.value = 12 + assert clamped.value == 12