Add timer component and made unittests pass
This commit is contained in:
parent
175fcce405
commit
f180e160f2
2 changed files with 268 additions and 41 deletions
|
|
@ -207,7 +207,7 @@ class TraitTest(_TraitHandlerBase):
|
||||||
"extra_val": 1000
|
"extra_val": 1000
|
||||||
}
|
}
|
||||||
expected = copy(dat) # we must break link or return === dat always
|
expected = copy(dat) # we must break link or return === dat always
|
||||||
self.assertEqual(expected, traits.Trait.validate_input(dat))
|
self.assertEqual(expected, traits.Trait.validate_input(traits.Trait, dat))
|
||||||
|
|
||||||
# don't supply value, should get default
|
# don't supply value, should get default
|
||||||
dat = {
|
dat = {
|
||||||
|
|
@ -218,7 +218,7 @@ class TraitTest(_TraitHandlerBase):
|
||||||
}
|
}
|
||||||
expected = copy(dat)
|
expected = copy(dat)
|
||||||
expected["value"] = traits.Trait.data_keys['value']
|
expected["value"] = traits.Trait.data_keys['value']
|
||||||
self.assertEqual(expected, traits.Trait.validate_input(dat))
|
self.assertEqual(expected, traits.Trait.validate_input(traits.Trait, dat))
|
||||||
|
|
||||||
# make sure extra values are cleaned if trait accepts no extras
|
# make sure extra values are cleaned if trait accepts no extras
|
||||||
dat = {
|
dat = {
|
||||||
|
|
@ -232,7 +232,7 @@ class TraitTest(_TraitHandlerBase):
|
||||||
expected.pop("extra_val1")
|
expected.pop("extra_val1")
|
||||||
expected.pop("extra_val2")
|
expected.pop("extra_val2")
|
||||||
with patch.object(traits.Trait, "allow_extra_properties", False):
|
with patch.object(traits.Trait, "allow_extra_properties", False):
|
||||||
self.assertEqual(expected, traits.Trait.validate_input(dat))
|
self.assertEqual(expected, traits.Trait.validate_input(traits.Trait, dat))
|
||||||
|
|
||||||
def test_validate_input__fail(self):
|
def test_validate_input__fail(self):
|
||||||
"""Test failing validation"""
|
"""Test failing validation"""
|
||||||
|
|
@ -243,7 +243,7 @@ class TraitTest(_TraitHandlerBase):
|
||||||
"extra_val": 1000
|
"extra_val": 1000
|
||||||
}
|
}
|
||||||
with self.assertRaises(traits.TraitException):
|
with self.assertRaises(traits.TraitException):
|
||||||
traits.Trait.validate_input(dat)
|
traits.Trait.validate_input(traits.Trait, dat)
|
||||||
|
|
||||||
# make value a required key
|
# make value a required key
|
||||||
mock_data_keys = {
|
mock_data_keys = {
|
||||||
|
|
@ -257,7 +257,7 @@ class TraitTest(_TraitHandlerBase):
|
||||||
"extra_val": 1000
|
"extra_val": 1000
|
||||||
}
|
}
|
||||||
with self.assertRaises(traits.TraitException):
|
with self.assertRaises(traits.TraitException):
|
||||||
traits.Trait.validate_input(dat)
|
traits.Trait.validate_input(traits.Trait, dat)
|
||||||
|
|
||||||
def test_trait_getset(self):
|
def test_trait_getset(self):
|
||||||
"""Get-set-del operations on trait"""
|
"""Get-set-del operations on trait"""
|
||||||
|
|
@ -433,6 +433,7 @@ class TestTraitCounter(_TraitHandlerBase):
|
||||||
},
|
},
|
||||||
"rate": 0,
|
"rate": 0,
|
||||||
"ratetarget": None,
|
"ratetarget": None,
|
||||||
|
"last_update": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -570,6 +571,84 @@ class TestTraitCounter(_TraitHandlerBase):
|
||||||
self.assertEqual(self.trait.desc(), "range3")
|
self.assertEqual(self.trait.desc(), "range3")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTraitCounterTimed(_TraitHandlerBase):
|
||||||
|
"""
|
||||||
|
Test for trait with timer component
|
||||||
|
"""
|
||||||
|
@patch("evennia.contrib.traits.time", new=MagicMock(return_value=1000))
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.traithandler.add(
|
||||||
|
"test1",
|
||||||
|
name="Test1",
|
||||||
|
trait_type='counter',
|
||||||
|
base=1,
|
||||||
|
mod=2,
|
||||||
|
min=0,
|
||||||
|
max=100,
|
||||||
|
extra_val1="xvalue1",
|
||||||
|
extra_val2="xvalue2",
|
||||||
|
descs={
|
||||||
|
0: "range0",
|
||||||
|
2: "range1",
|
||||||
|
5: "range2",
|
||||||
|
7: "range3",
|
||||||
|
},
|
||||||
|
rate=1,
|
||||||
|
ratetarget=None,
|
||||||
|
)
|
||||||
|
self.trait = self.traithandler.get("test1")
|
||||||
|
|
||||||
|
def _get_timer_data(self):
|
||||||
|
return (self.trait.actual, self.trait.current, self.trait.rate,
|
||||||
|
self.trait._data["last_update"], self.trait.ratetarget)
|
||||||
|
|
||||||
|
@patch("evennia.contrib.traits.time")
|
||||||
|
def test_timer_rate(self, mock_time):
|
||||||
|
"""Test time stepping"""
|
||||||
|
mock_time.return_value = 1000
|
||||||
|
self.assertEqual(self._get_timer_data(), (3, 1, 1, 1000, None))
|
||||||
|
mock_time.return_value = 1001
|
||||||
|
self.assertEqual(self._get_timer_data(), (4, 2, 1, 1001, None))
|
||||||
|
mock_time.return_value = 1096
|
||||||
|
self.assertEqual(self._get_timer_data(), (99, 97, 1, 1096, None))
|
||||||
|
# hit maximum boundary
|
||||||
|
mock_time.return_value = 1120
|
||||||
|
self.assertEqual(self._get_timer_data(), (100, 98, 1, None, None))
|
||||||
|
mock_time.return_value = 1200
|
||||||
|
self.assertEqual(self._get_timer_data(), (100, 98, 1, None, None))
|
||||||
|
# drop current
|
||||||
|
self.trait.current = 50
|
||||||
|
self.assertEqual(self._get_timer_data(), (52, 50, 1, 1200, None))
|
||||||
|
# set a new rate
|
||||||
|
self.trait.rate = 2
|
||||||
|
mock_time.return_value = 1210
|
||||||
|
self.assertEqual(self._get_timer_data(), (72, 70, 2, 1210, None))
|
||||||
|
self.trait.rate = -10
|
||||||
|
mock_time.return_value = 1214
|
||||||
|
self.assertEqual(self._get_timer_data(), (32, 30, -10, 1214, None))
|
||||||
|
mock_time.return_value = 1218
|
||||||
|
self.assertEqual(self._get_timer_data(), (0, -2, -10, None, None))
|
||||||
|
|
||||||
|
@patch("evennia.contrib.traits.time")
|
||||||
|
def test_timer_ratetarget(self, mock_time):
|
||||||
|
"""test ratetarget"""
|
||||||
|
mock_time.return_value = 1000
|
||||||
|
self.trait.ratetarget = 60
|
||||||
|
self.assertEqual(self._get_timer_data(), (3, 1, 1, 1000, 60))
|
||||||
|
mock_time.return_value = 1056
|
||||||
|
self.assertEqual(self._get_timer_data(), (59, 57, 1, 1056, 60))
|
||||||
|
mock_time.return_value = 1057
|
||||||
|
self.assertEqual(self._get_timer_data(), (60, 58, 1, None, 60))
|
||||||
|
mock_time.return_value = 1060
|
||||||
|
self.assertEqual(self._get_timer_data(), (60, 58, 1, None, 60))
|
||||||
|
self.trait.ratetarget = 70
|
||||||
|
mock_time.return_value = 1066
|
||||||
|
self.assertEqual(self._get_timer_data(), (66, 64, 1, 1066, 70))
|
||||||
|
mock_time.return_value = 1070
|
||||||
|
self.assertEqual(self._get_timer_data(), (70, 68, 1, None, 70))
|
||||||
|
|
||||||
|
|
||||||
class TestTraitGauge(_TraitHandlerBase):
|
class TestTraitGauge(_TraitHandlerBase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
@ -614,6 +693,7 @@ class TestTraitGauge(_TraitHandlerBase):
|
||||||
},
|
},
|
||||||
"rate": 0,
|
"rate": 0,
|
||||||
"ratetarget": None,
|
"ratetarget": None,
|
||||||
|
"last_update": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
def test_actual(self):
|
def test_actual(self):
|
||||||
|
|
@ -756,6 +836,85 @@ class TestTraitGauge(_TraitHandlerBase):
|
||||||
self.assertEqual(self.trait.desc(), "range3")
|
self.assertEqual(self.trait.desc(), "range3")
|
||||||
|
|
||||||
|
|
||||||
|
class TestTraitGaugeTimed(_TraitHandlerBase):
|
||||||
|
"""
|
||||||
|
Test for trait with timer component
|
||||||
|
"""
|
||||||
|
@patch("evennia.contrib.traits.time", new=MagicMock(return_value=1000))
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.traithandler.add(
|
||||||
|
"test1",
|
||||||
|
name="Test1",
|
||||||
|
trait_type='gauge',
|
||||||
|
base=98,
|
||||||
|
mod=2,
|
||||||
|
min=0,
|
||||||
|
extra_val1="xvalue1",
|
||||||
|
extra_val2="xvalue2",
|
||||||
|
descs={
|
||||||
|
0: "range0",
|
||||||
|
2: "range1",
|
||||||
|
5: "range2",
|
||||||
|
7: "range3",
|
||||||
|
},
|
||||||
|
rate=1,
|
||||||
|
ratetarget=None,
|
||||||
|
)
|
||||||
|
self.trait = self.traithandler.get("test1")
|
||||||
|
|
||||||
|
def _get_timer_data(self):
|
||||||
|
return (self.trait.actual, self.trait.current, self.trait.rate,
|
||||||
|
self.trait._data["last_update"], self.trait.ratetarget)
|
||||||
|
|
||||||
|
@patch("evennia.contrib.traits.time")
|
||||||
|
def test_timer_rate(self, mock_time):
|
||||||
|
"""Test time stepping"""
|
||||||
|
mock_time.return_value = 1000
|
||||||
|
self.trait.current = 1
|
||||||
|
self.assertEqual(self._get_timer_data(), (1, 1, 1, 1000, None))
|
||||||
|
mock_time.return_value = 1001
|
||||||
|
self.assertEqual(self._get_timer_data(), (2, 2, 1, 1001, None))
|
||||||
|
mock_time.return_value = 1096
|
||||||
|
self.assertEqual(self._get_timer_data(), (97, 97, 1, 1096, None))
|
||||||
|
# hit maximum boundary
|
||||||
|
mock_time.return_value = 1120
|
||||||
|
self.assertEqual(self._get_timer_data(), (100, 100, 1, None, None))
|
||||||
|
mock_time.return_value = 1200
|
||||||
|
self.assertEqual(self._get_timer_data(), (100, 100, 1, None, None))
|
||||||
|
# drop current
|
||||||
|
self.trait.current = 50
|
||||||
|
self.assertEqual(self._get_timer_data(), (50, 50, 1, 1200, None))
|
||||||
|
# set a new rate
|
||||||
|
self.trait.rate = 2
|
||||||
|
mock_time.return_value = 1210
|
||||||
|
self.assertEqual(self._get_timer_data(), (70, 70, 2, 1210, None))
|
||||||
|
self.trait.rate = -10
|
||||||
|
mock_time.return_value = 1214
|
||||||
|
self.assertEqual(self._get_timer_data(), (30, 30, -10, 1214, None))
|
||||||
|
mock_time.return_value = 1218
|
||||||
|
self.assertEqual(self._get_timer_data(), (0, 0, -10, None, None))
|
||||||
|
|
||||||
|
@patch("evennia.contrib.traits.time")
|
||||||
|
def test_timer_ratetarget(self, mock_time):
|
||||||
|
"""test ratetarget"""
|
||||||
|
mock_time.return_value = 1000
|
||||||
|
self.trait.current = 1
|
||||||
|
self.trait.ratetarget = 60
|
||||||
|
self.assertEqual(self._get_timer_data(), (1, 1, 1, 1000, 60))
|
||||||
|
mock_time.return_value = 1056
|
||||||
|
self.assertEqual(self._get_timer_data(), (57, 57, 1, 1056, 60))
|
||||||
|
mock_time.return_value = 1059
|
||||||
|
self.assertEqual(self._get_timer_data(), (60, 60, 1, None, 60))
|
||||||
|
mock_time.return_value = 1060
|
||||||
|
self.assertEqual(self._get_timer_data(), (60, 60, 1, None, 60))
|
||||||
|
self.trait.ratetarget = 70
|
||||||
|
mock_time.return_value = 1066
|
||||||
|
self.assertEqual(self._get_timer_data(), (66, 66, 1, 1066, 70))
|
||||||
|
mock_time.return_value = 1070
|
||||||
|
self.assertEqual(self._get_timer_data(), (70, 70, 1, None, 70))
|
||||||
|
|
||||||
|
|
||||||
class TestNumericTraitOperators(TestCase):
|
class TestNumericTraitOperators(TestCase):
|
||||||
"""Test case for numeric magic method implementations."""
|
"""Test case for numeric magic method implementations."""
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
|
||||||
|
|
@ -487,7 +487,7 @@ class TraitHandler:
|
||||||
trait_properties["trait_type"] = trait_type
|
trait_properties["trait_type"] = trait_type
|
||||||
|
|
||||||
# this will raise exception if input is insufficient
|
# this will raise exception if input is insufficient
|
||||||
trait_properties = trait_class.validate_input(trait_properties)
|
trait_properties = trait_class.validate_input(trait_class, trait_properties)
|
||||||
|
|
||||||
self.trait_data[trait_key] = trait_properties
|
self.trait_data[trait_key] = trait_properties
|
||||||
|
|
||||||
|
|
@ -563,7 +563,7 @@ class Trait:
|
||||||
TraitException: If input-validation failed.
|
TraitException: If input-validation failed.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
self._data = self.__class__.validate_input(trait_data)
|
self._data = self.__class__.validate_input(self.__class__, trait_data)
|
||||||
|
|
||||||
if not isinstance(trait_data, _SaverDict):
|
if not isinstance(trait_data, _SaverDict):
|
||||||
logger.log_warn(
|
logger.log_warn(
|
||||||
|
|
@ -571,7 +571,7 @@ class Trait:
|
||||||
f"loaded for {type(self).__name__}."
|
f"loaded for {type(self).__name__}."
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def validate_input(cls, trait_data):
|
def validate_input(cls, trait_data):
|
||||||
"""
|
"""
|
||||||
Validate input
|
Validate input
|
||||||
|
|
@ -967,55 +967,90 @@ class CounterTrait(NumericTrait):
|
||||||
"ratetarget": None
|
"ratetarget": None
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@staticmethod
|
||||||
def validate(cls, trait_data):
|
def validate_input(cls, trait_data):
|
||||||
"""Add extra validation for descs"""
|
"""Add extra validation for descs"""
|
||||||
trait_data = Trait.validate_input(trait_data)
|
trait_data = Trait.validate_input(cls, trait_data)
|
||||||
|
# validate descs
|
||||||
descs = trait_data['descs']
|
descs = trait_data['descs']
|
||||||
if isinstance(descs, dict):
|
if isinstance(descs, dict):
|
||||||
if any(not (isinstance(key, (int, float)) and isinstance(value, str))
|
if any(not (isinstance(key, (int, float)) and isinstance(value, str))
|
||||||
for key in descs.items()):
|
for key, value in descs.items()):
|
||||||
raise TraitException("Trait descs must be defined on the form {number:str}")
|
raise TraitException(
|
||||||
|
f"Trait descs must be defined on the "
|
||||||
|
f"form {{number:str}} (instead found {descs}).")
|
||||||
|
# set up rate
|
||||||
if trait_data['rate'] != 0:
|
if trait_data['rate'] != 0:
|
||||||
trait_data['last_update'] = time()
|
trait_data['last_update'] = time()
|
||||||
|
else:
|
||||||
|
trait_data['last_update'] = None
|
||||||
return trait_data
|
return trait_data
|
||||||
|
|
||||||
|
# Helpers
|
||||||
|
|
||||||
|
def _within_boundaries(self, value):
|
||||||
|
"""Check if given value is within boundaries"""
|
||||||
|
return not (
|
||||||
|
(self.min is not None and value <= self.min) or
|
||||||
|
(self.max is not None and value >= self.max)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _enforce_boundaries(self, value):
|
||||||
|
"""Ensures that incoming value falls within boundaries"""
|
||||||
|
if self.min is not None and value <= self.min:
|
||||||
|
return self.min
|
||||||
|
if self.max is not None and value >= self.max:
|
||||||
|
return self.max
|
||||||
|
return value
|
||||||
|
|
||||||
# timer component
|
# timer component
|
||||||
|
|
||||||
def _timer_running(self):
|
def _passed_ratetarget(self, value):
|
||||||
"""Check if timer mechanism is running"""
|
"""Check if we passed the ratetarget in either direction."""
|
||||||
return self.rate != 0 and self._data['last_update'] is not None
|
ratetarget = self._data['ratetarget']
|
||||||
|
return (ratetarget is not None and (
|
||||||
|
(self.rate < 0 and value <= ratetarget) or
|
||||||
|
(self.rate > 0 and value >= ratetarget)))
|
||||||
|
|
||||||
def _stop_timer(self):
|
def _stop_timer(self):
|
||||||
if self._timer_running():
|
"""Stop rate-timer component."""
|
||||||
|
if self.rate != 0 and self._data['last_update'] is not None:
|
||||||
self._data['last_update'] = None
|
self._data['last_update'] = None
|
||||||
|
|
||||||
def _check_ratetarget(self):
|
def _check_and_start_timer(self, value):
|
||||||
"""Check if we passed ratetarget."""
|
"""Start timer if we are not at a boundary."""
|
||||||
ratetarget = self._data['ratetarget']
|
if self.rate != 0 and self._data['last_update'] is None:
|
||||||
return (ratetarget is not None and
|
ratetarget = self._data['ratetarget']
|
||||||
((self.rate < 0 and new_curr <= ratetarget) or
|
if self._within_boundaries(value) and not self._passed_ratetarget(value):
|
||||||
(self.rate > 0 and new_curr >= ratetarget)))
|
# we are not at a boundary [anymore].
|
||||||
|
self._data['last_update'] = time()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _update_current(self, current):
|
def _update_current(self, current):
|
||||||
"""Update current value, including any rate change"""
|
"""Update current value by scaling with rate and time passed."""
|
||||||
if self.rate != 0 and self._data['last_update'] is not None:
|
rate = self.rate
|
||||||
|
if rate != 0 and self._data['last_update'] is not None:
|
||||||
|
now = time()
|
||||||
tdiff = now - self._data['last_update']
|
tdiff = now - self._data['last_update']
|
||||||
current += self.rate * tdiff
|
current += rate * tdiff
|
||||||
return current
|
actual = current + self.mod
|
||||||
|
|
||||||
def _enforce_boundaries(self, value):
|
# we must make sure so we don't overstep our bounds
|
||||||
"""Ensures that incoming value falls within trait's range."""
|
# even if .mod is included
|
||||||
if self.min is not None and value <= self.min:
|
|
||||||
self._stop_timer()
|
if self._passed_ratetarget(actual):
|
||||||
return self.min
|
current = self._data['ratetarget'] - self.mod
|
||||||
if self.max is not None and value >= self.max:
|
self._stop_timer()
|
||||||
self._stop_timer()
|
elif not self._within_boundaries(actual):
|
||||||
return self.max
|
current = self._enforce_boundaries(actual) - self.mod
|
||||||
if self._timer_running() and self._check_ratetarget():
|
self._stop_timer()
|
||||||
_stop_timer()
|
else:
|
||||||
return self._data['ratetarget']
|
self._data['last_update'] = now
|
||||||
return value
|
|
||||||
|
self._data['current'] = current
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
# properties
|
# properties
|
||||||
|
|
||||||
|
|
@ -1086,7 +1121,7 @@ class CounterTrait(NumericTrait):
|
||||||
@current.setter
|
@current.setter
|
||||||
def current(self, value):
|
def current(self, value):
|
||||||
if type(value) in (int, float):
|
if type(value) in (int, float):
|
||||||
self._data["current"] = self._enforce_boundaries(value)
|
self._data["current"] = self._check_and_start_timer(self._enforce_boundaries(value))
|
||||||
|
|
||||||
@current.deleter
|
@current.deleter
|
||||||
def current(self):
|
def current(self):
|
||||||
|
|
@ -1098,6 +1133,15 @@ class CounterTrait(NumericTrait):
|
||||||
"The actual value of the Trait (current + mod)"
|
"The actual value of the Trait (current + mod)"
|
||||||
return self._enforce_boundaries(self.current + self.mod)
|
return self._enforce_boundaries(self.current + self.mod)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ratetarget(self):
|
||||||
|
return self._data['ratetarget']
|
||||||
|
|
||||||
|
@ratetarget.setter
|
||||||
|
def ratetarget(self, value):
|
||||||
|
self._data['ratetarget'] = self._enforce_boundaries(value)
|
||||||
|
self._check_and_start_timer(self.actual)
|
||||||
|
|
||||||
def percent(self, formatting="{:3.1f}%"):
|
def percent(self, formatting="{:3.1f}%"):
|
||||||
"""
|
"""
|
||||||
Return the current value as a percentage.
|
Return the current value as a percentage.
|
||||||
|
|
@ -1188,6 +1232,30 @@ class GaugeTrait(CounterTrait):
|
||||||
"ratetarget": None,
|
"ratetarget": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _update_current(self, current):
|
||||||
|
"""Update current value by scaling with rate and time passed."""
|
||||||
|
rate = self.rate
|
||||||
|
if rate != 0 and self._data['last_update'] is not None:
|
||||||
|
now = time()
|
||||||
|
tdiff = now - self._data['last_update']
|
||||||
|
current += rate * tdiff
|
||||||
|
actual = current
|
||||||
|
|
||||||
|
# we don't worry about .mod for gauges
|
||||||
|
|
||||||
|
if self._passed_ratetarget(actual):
|
||||||
|
current = self._data['ratetarget']
|
||||||
|
self._stop_timer()
|
||||||
|
elif not self._within_boundaries(actual):
|
||||||
|
current = self._enforce_boundaries(actual)
|
||||||
|
self._stop_timer()
|
||||||
|
else:
|
||||||
|
self._data['last_update'] = now
|
||||||
|
|
||||||
|
self._data['current'] = current
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
def _enforce_boundaries(self, value):
|
def _enforce_boundaries(self, value):
|
||||||
"""Ensures that incoming value falls within trait's range."""
|
"""Ensures that incoming value falls within trait's range."""
|
||||||
if self.min is not None and value <= self.min:
|
if self.min is not None and value <= self.min:
|
||||||
|
|
@ -1258,7 +1326,7 @@ class GaugeTrait(CounterTrait):
|
||||||
@current.setter
|
@current.setter
|
||||||
def current(self, value):
|
def current(self, value):
|
||||||
if type(value) in (int, float):
|
if type(value) in (int, float):
|
||||||
self._data["current"] = self._enforce_boundaries(value)
|
self._data["current"] = self._check_and_start_timer(self._enforce_boundaries(value))
|
||||||
|
|
||||||
@current.deleter
|
@current.deleter
|
||||||
def current(self):
|
def current(self):
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue