diff --git a/evennia/contrib/test_traits.py b/evennia/contrib/test_traits.py index 6c7bcd1b8f..231a14800f 100644 --- a/evennia/contrib/test_traits.py +++ b/evennia/contrib/test_traits.py @@ -104,6 +104,8 @@ class TraitHandlerTest(_TraitHandlerBase): self.traithandler.foo = "bar" with self.assertRaises(traits.TraitException): self.traithandler["foo"] = "bar" + with self.assertRaises(traits.TraitException): + self.traithandler.test1 = "foo" def test_getting(self): "Test we are getting data from the dbstore" @@ -294,74 +296,377 @@ class TraitTest(_TraitHandlerBase): class TestTraitNumeric(_TraitHandlerBase): + """ + Test the numeric base class + """ - def test_trait__numeric(self): + def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type='numeric', + base=1, + extra_val1="xvalue1", + extra_val2="xvalue2" + ) + self.trait1 = self.traithandler.get("test1") + + def _get_actuals(self): + """Get trait actuals for comparisons""" + return self.trait1.actual, self.trait2.actual + + def test_init(self): + self.assertEqual( + self.trait1._data, + {"name": "Test1", + "trait_type": "numeric", + "base": 1, + "extra_val1": "xvalue1", + "extra_val2": "xvalue2" + } + ) + + def test_set_wrong_type(self): + self.trait1.base = "foo" + self.assertEqual(self.trait1.base, 1) + + def test_actual(self): + self.trait1.base = 10 + self.assertEqual(self.trait1.actual, 10) + + +class TestTraitStatic(_TraitHandlerBase): + """ + Test for static Traits + """ + def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type='static', + base=1, + mod=2, + extra_val1="xvalue1", + extra_val2="xvalue2" + ) + self.trait1 = self.traithandler.get("test1") + + def _get_values(self): + return self.trait1.base, self.trait1.mod, self.trait1.actual + + def test_init(self): + self.assertEqual( + self._get_dbstore("test1"), + {"name": "Test1", + "trait_type": 'static', + "base": 1, + "mod": 2, + "extra_val1": "xvalue1", + "extra_val2": "xvalue2" + } + ) + + def test_actual(self): + """Actual is base + mod""" + self.assertEqual(self._get_values(), (1, 2, 3)) + self.trait1.base += 4 + self.assertEqual(self._get_values(), (5, 2, 7)) + self.trait1.mod -= 1 + self.assertEqual(self._get_values(), (5, 1, 6)) + + def test_delete(self): + """Deleting resets to default.""" + del self.trait1.base + self.assertEqual(self._get_values(), (0, 2, 2)) + del self.trait1.mod + self.assertEqual(self._get_values(), (0, 0, 0)) + + +class TestTraitCounter(_TraitHandlerBase): + """ + Test for counter- Traits + """ + def setUp(self): + super().setUp() + self.traithandler.add( + "test1", + name="Test1", + trait_type='counter', + base=1, + mod=2, + min=-10, + max=10, + extra_val1="xvalue1", + extra_val2="xvalue2" + ) + self.trait1 = self.traithandler.get("test1") + + def _get_values(self): + return self.trait1.base, self.trait1.mod, self.trait1.actual + + def test_init(self): + self.assertEqual( + self._get_dbstore("test1"), + {"name": "Test1", + "trait_type": 'counter', + "base": 1, + "mod": 2, + "min": -10, + "max": 10, + "extra_val1": "xvalue1", + "extra_val2": "xvalue2" + } + ) + + def test_actual(self): + """Actual is current + mod, where current defaults to base""" + self.assertEqual(self._get_values(), (1, 2, 3)) + self.trait1.base += 4 + self.assertEqual(self._get_values(), (5, 2, 7)) + self.trait1.mod -= 1 + self.assertEqual(self._get_values(), (5, 1, 6)) + + def test_boundaries__minmax(self): + """Test range""" + # should not exceed min/max values + self.trait1.base += 20 + self.assertEqual(self._get_values(), (10, 2, 10)) + self.trait1.base = 100 + self.assertEqual(self._get_values(), (10, 2, 10)) + self.trait1.base -= 40 + self.assertEqual(self._get_values(), (-10, 2, -8)) + self.trait1.base = -100 + self.assertEqual(self._get_values(), (-10, 2, -8)) + + def test_boundaries__bigmod(self): + """add a big mod""" + self.trait1.base = 5 + self.trait1.mod = 100 + self.assertEqual(self._get_values(), (5, 100, 10)) + self.trait1.mod = -100 + self.assertEqual(self._get_values(), (5, -100, -10)) + + def test_boundaries__change_boundaries(self): + """Change boundaries after base/mod change""" + self.trait1.base = 5 + self.trait1.mod = -100 + self.trait1.min = -20 + self.assertEqual(self._get_values(), (5, -100, -20)) + self.trait1.mod = 100 + self.trait1.max = 20 + self.assertEqual(self._get_values(), (5, 100, 20)) + + def test_boundaries__base_literal(self): + """Use the "base" literal makes the max become base+mod""" + self.trait1.base = 5 + self.trait1.mod = 100 + self.trait1.max = "base" + self.assertEqual(self._get_values(), (5, 100, 105)) + + def test_boundaries__disable(self): + """Disable and re-enable boundaries""" + self.trait1.base = 5 + self.trait1.mod = 100 + del self.trait1.max + self.assertEqual(self.trait1.max, None) + del self.trait1.min + self.assertEqual(self.trait1.min, None) + self.trait1.base = 100 + self.assertEqual(self._get_values(), (100, 100, 200)) + self.trait1.base = -10 + self.assertEqual(self._get_values(), (-10, 100, 90)) + + # re-activate boundaries + self.trait1.max = 15 + self.trait1.min = 10 + self.assertEqual(self._get_values(), (-10, 100, 15)) + + def test_boundaries__inverse(self): + """Set inverse boundaries - limited by base""" + self.trait1.base = -10 + self.trait1.mod = 100 + self.trait1.min = 20 # will be set to base + self.assertEqual(self.trait1.min, -10) + self.trait1.max = -20 + self.assertEqual(self.trait1.max, -10) + self.assertEqual(self._get_values(), (-10, 100, -10)) + + def test_current(self): + """Modifying current value""" + self.trait1.current = 5 + self.assertEqual(self._get_values(), (1, 2, 7)) + self.trait1.current = 10 + self.assertEqual(self._get_values(), (1, 2, 10)) + self.trait1.current = 12 + self.assertEqual(self._get_values(), (1, 2, 10)) + + def test_delete(self): + """Deleting resets to default.""" + del self.trait1.base + self.assertEqual(self._get_values(), (0, 2, 2)) + del self.trait1.mod + self.assertEqual(self._get_values(), (0, 0, 0)) + del self.trait1.min + del self.trait1.max + self.assertEqual(self.trait1.max, None) + self.assertEqual(self.trait1.min, None) + + +class TestTraitGauge(TestTraitCounter): + + def setUp(self): + super().setUp() self.traithandler.add( "test2", - name="Test2", - trait_type='numeric', - ) - self.assertEqual( - self._get_dbstore("test2"), - {"name": "Test2", - "trait_type": 'numeric', - "base": 0, - } + name="Test1", + trait_type='gauge', + base=1, + mod=2, + min=-10, + max=10, + extra_val1="xvalue1", + extra_val2="xvalue2" ) + self.trait1 = self.traithandler.get("test2") + + def test_boundaries__change_boundaries(self): + """Change boundaries after base/mod change""" + self.trait1.base = 5 + self.trait1.mod = -100 + self.trait1.min = -20 + # from pudb import debugger;debugger.Debugger().set_trace() + self.assertEqual(self._get_values(), (5, -100, -20)) + self.trait1.mod = 100 + self.trait1.max = 20 + self.assertEqual(self._get_values(), (5, 100, 20)) + + def test_boundaries__disable(self): + """Disable and re-enable boundaries""" + self.trait1.base = 5 + self.trait1.mod = 100 + del self.trait1.max + self.assertEqual(self.trait1.max, None) + del self.trait1.min + self.assertEqual(self.trait1.min, None) + self.trait1.base = 100 + # this won't change since current is not changed + self.assertEqual(self._get_values(), (100, 100, 10)) + self.trait1.current = 150 + self.assertEqual(self._get_values(), (100, 100, 150)) + self.trait1.base = -10 + self.assertEqual(self._get_values(), (-10, 100, 150)) + + # re-activate boundaries + self.trait1.max = 15 + self.trait1.min = 10 + self.assertEqual(self._get_values(), (-10, 100, 15)) + + def test_boundaries__inverse(self): + """Set inverse boundaries - limited by base""" + self.trait1.base = -10 + self.trait1.mod = 100 + self.trait1.min = 20 # will be set to base + self.assertEqual(self.trait1.min, -10) + self.trait1.max = -20 # this is = self.mod + self.base: + if self._data["max"] == "base" and value >= self.mod + self.base: return self.mod + self.base if self.max is not None and value >= self.max: return self.max @@ -894,42 +939,39 @@ class CounterTrait(NumericTrait): # properties - @property - def actual(self): - "The actual value of the Trait" - return self._mod_current() - @property def base(self): return self._data["base"] @base.setter def base(self, amount): - if self._data.get("max_value", None) == "base": - self._data["base"] = amount + if self._data.get("max", None) == "base": + self._data["base"] = amount if type(amount) in (int, float): self._data["base"] = self._enforce_bounds(amount) @property def min(self): - return self._data["min_value"] + return self._data["min"] @min.setter - def min(self, amount): - if amount is None: - self._data["min_value"] = amount - elif type(amount) in (int, float): - self._data["min_value"] = amount if amount < self.base else self.base + def min(self, value): + if value is None: + self._data["min"] = value + elif type(value) in (int, float): + if self.max is not None: + value = min(self.max, value) + self._data["min"] = value if value < self.base else self.base @property def max(self): - if self._data["max_value"] == "base": + if self._data["max"] == "base": return self._mod_base() - return self._data["max_value"] + return self._data["max"] @max.setter def max(self, value): - """The maximum value of the `Trait`. + """The maximum value of the trait. Note: This property may be set to the string literal 'base'. @@ -937,20 +979,27 @@ class CounterTrait(NumericTrait): `mod`+`base` properties. """ if value == "base" or value is None: - self._data["max_value"] = value + self._data["max"] = value elif type(value) in (int, float): - self._data["max_value"] = value if value > self.base else self.base + if self.min is not None: + value = max(self.min, value) + self._data["max"] = value if value > self.base else self.base @property def current(self): """The `current` value of the `Trait`.""" - return self._data.get("current", self.base) + return self._enforce_bounds(self._data.get("current", self.base)) @current.setter def current(self, value): if type(value) in (int, float): self._data["current"] = self._enforce_bounds(value) + @property + def actual(self): + "The actual value of the Trait" + return self._mod_current() + def reset_mod(self): """Clears any mod value to 0.""" self.mod = 0 @@ -959,76 +1008,141 @@ class CounterTrait(NumericTrait): """Resets `current` property equal to `base` value.""" self.current = self.base - def percent(self): - """Returns the value formatted as a percentage.""" - if self.max: - return "{:3.1f}%".format(self.current * 100.0 / self.max) - elif self.base != 0: - return "{:3.1f}%".format(self.current * 100.0 / self._mod_base()) - # if we get to this point, it's may be a divide by zero situation - return "100.0%" + def percent(self, formatting="{:3.1f}%"): + """ + Return the current value as a percentage. + + Args: + formatting (str, optional): Should contain a + format-tag which will receive the value. If + this is set to None, the raw float will be + returned. + Returns: + float or str: Depending of if a `formatting` string + is supplied or not. + """ + return percent(self.current, self.min, self.max, formatting=formatting) class GaugeTrait(CounterTrait): """ Gauge Trait. - This emulates a gauge-meter that can be reset. + This emulates a gauge-meter that empties from a base+mod value. + + min/0 max=base + mod + |-----------------------X---------------------------| + actual + = current + + - min defaults to 0 + - max value is always base + mad + - .max is an alias of .base + - actual = current and varies from min to max. """ trait_type = "gauge" # same as Counter, here for easy reference + # current starts out equal to base data_keys = { "base": 0, "mod": 0, - "current": 0, - "min_value": None, - "max_value": None, + "min": None, } + def _mod_base(self): + """Calculate adding base and modifications""" + return self._enforce_bounds(self.mod + self.base) + + def _mod_current(self): + """Calculate the current value""" + return self._enforce_bounds(self.current) + + def _enforce_bounds(self, value): + """Ensures that incoming value falls within trait's range.""" + if self.min is not None and value <= self.min: + return self.min + return min(self.mod + self.base, value) + def __str__(self): status = "{actual:4} / {base:4}".format(actual=self.actual, base=self.base) return "{name:12} {status} ({mod:+3})".format(name=self.name, status=status, mod=self.mod) @property - def actual(self): - "The actual value of the trait" - return self.current + def base(self): + return self._data["base"] + + @base.setter + def base(self, value): + if type(value) in (int, float): + self._data["base"] = self._enforce_bounds(value) @property def mod(self): - """The trait's modifier.""" return self._data["mod"] @mod.setter def mod(self, amount): if type(amount) in (int, float): self._data["mod"] = amount - delta = amount - self._data["mod"] - if delta >= 0: - # apply increases to current - self.current = self._enforce_bounds(self.current + delta) - else: - # but not decreases, unless current goes out of range - self.current = self._enforce_bounds(self.current) + + @property + def min(self): + return self._data["min"] + + @min.setter + def min(self, value): + if value is None: + self._data["min"] = self.data_keys['min'] + elif type(value) in (int, float): + self._data["min"] = min(self.value, self.base + self.mod) + + @property + def max(self): + "The max is always base + mod." + return self.base + self.mod + + @max.setter + def max(self, value): + raise TraitException("The .max property is not settable " + "on GaugeTraits. Set .base instead.") @property def current(self): - """The `current` value of the `Trait`.""" - return self._data.get("current", self._mod_base()) + """The `current` value of the gauge.""" + return self._enforce_bounds(self._data.get("current", self._mod_base())) @current.setter def current(self, value): if type(value) in (int, float): self._data["current"] = self._enforce_bounds(value) - def fill_gauge(self): - """Adds the `mod`+`base` to the `current` value. + @property + def actual(self): + "The actual value of the trait" + return self.current - Note: - Will honor the upper bound if set. + def percent(self, formatting="{:3.1f}%"): + """ + Return the current value as a percentage. + + Args: + formatting (str, optional): Should contain a + format-tag which will receive the value. If + this is set to None, the raw float will be + returned. + Returns: + float or str: Depending of if a `formatting` string + is supplied or not. + """ + return percent(self.current, self.min, self.max, formatting=formatting) + + + def fill_gauge(self): + """ + Fills the gauge to its maximum allowed by base + mod """ - self.current = self._enforce_bounds(self.current + self._mod_base()) + self.current = self.base + self.mod diff --git a/evennia/utils/tests/test_utils.py b/evennia/utils/tests/test_utils.py index 39cbdbf2fa..57bbf8a3e1 100644 --- a/evennia/utils/tests/test_utils.py +++ b/evennia/utils/tests/test_utils.py @@ -312,3 +312,21 @@ class TestFormatGrid(TestCase): self.assertEqual(len(rows), 8) for element in elements: self.assertTrue(element in "\n".join(rows), f"element {element} is missing.") + + +class TestPercent(TestCase): + """ + Test the utils.percentage function. + """ + def test_ok_input(self): + result = utils.percentage(3, 0, 10) + self.assertEqual(result, "30.0%") + result = utils.percentage(2.5, 5, 10, formatting=None) + self.assertEqual(result, 50.0) + # min==max we set to 100% + self.assertEqual(utils.percentage(4, 5, 5), "100.0%") + + def test_bad_input(self): + self.assertRaises(RuntimeError): + utils.percentage(3, 10, 1) + diff --git a/evennia/utils/utils.py b/evennia/utils/utils.py index 4faf76ca42..810bc664e0 100644 --- a/evennia/utils/utils.py +++ b/evennia/utils/utils.py @@ -1687,6 +1687,48 @@ def format_table(table, extra_space=1): return ftable +def percent(self, value, minval, maxval, formatting="{:3.1f}%"): + """ + Get a value in an interval as a percentage of its position + in that interval. This also understands negative numbers. + + Args: + value (number): This should be a value minval<=value<=maxval. + minval (number): Smallest value in interval. + maxval (number): Biggest value in interval. + formatted (str, optional): This is a string that should + accept one formatting tag. This will receive the + current value as a percentage. If None, the + raw float will be returned instead. + Returns: + str or float: The formatted value or the raw percentage + as a float. + Raises: + RuntimeError: If min/max does not make sense. + Notes: + We handle the case of minval==maxval because we may see this case and + don't want to raise exceptions unnecessarily. In that case we return + 100%. + + """ + if minval > maxval: + raise RuntimeError("The minimum value must be <= the max value.") + # constrain value to interval + value = min(max(minval, value), maxval) + + # these should both be >0 + dpart = value - minval + dfull = maxval - minval + try: + result = (dpart / dfull) * 100.0 + except ZeroDivisionError: + # this means minval == maxval + result = 100.0 + if not isinstance(formatting, str): + return result + return formatting.format(result) + + import functools # noqa