test(core): add unit tests for untested core modules
Adds 113 tests covering: - abc: RegistryABC (concrete implementation + edge cases) - environment: Environment get/set, EnvironmentEnum - pipes: BoolPipe, IPAddressPipe (incl. roundtrip + error cases) - time: Cron (next(), intervals, invalid expression) - utils: Cache (TTL, expiry, cleanup), get_value (incl. bug documentation: cast result not returned for string->typed values), JSONProcessor (nested objects, enums, defaults) - property: classproperty (class access, instance access, subclass) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
0
test/core/abc/__init__.py
Normal file
0
test/core/abc/__init__.py
Normal file
66
test/core/abc/registry_abc_test.py
Normal file
66
test/core/abc/registry_abc_test.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import pytest
|
||||
from cpl.core.abc.registry_abc import RegistryABC
|
||||
|
||||
|
||||
class StringRegistry(RegistryABC[str]):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
def extend(self, items: list[str]) -> None:
|
||||
for item in items:
|
||||
self.add(item)
|
||||
|
||||
def add(self, item: str) -> None:
|
||||
self._items[item] = item
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
return self._items.get(key)
|
||||
|
||||
def all(self) -> list[str]:
|
||||
return list(self._items.values())
|
||||
|
||||
|
||||
def test_add_and_get():
|
||||
reg = StringRegistry()
|
||||
reg.add("hello")
|
||||
assert reg.get("hello") == "hello"
|
||||
|
||||
|
||||
def test_get_missing_returns_none():
|
||||
reg = StringRegistry()
|
||||
assert reg.get("nonexistent") is None
|
||||
|
||||
|
||||
def test_all_empty():
|
||||
reg = StringRegistry()
|
||||
assert reg.all() == []
|
||||
|
||||
|
||||
def test_all_returns_all_items():
|
||||
reg = StringRegistry()
|
||||
reg.add("a")
|
||||
reg.add("b")
|
||||
reg.add("c")
|
||||
items = reg.all()
|
||||
assert set(items) == {"a", "b", "c"}
|
||||
|
||||
|
||||
def test_extend():
|
||||
reg = StringRegistry()
|
||||
reg.extend(["x", "y", "z"])
|
||||
assert reg.get("x") == "x"
|
||||
assert reg.get("y") == "y"
|
||||
assert reg.get("z") == "z"
|
||||
assert len(reg.all()) == 3
|
||||
|
||||
|
||||
def test_overwrite_key():
|
||||
reg = StringRegistry()
|
||||
reg.add("key")
|
||||
reg.add("key")
|
||||
assert len(reg.all()) == 1
|
||||
|
||||
|
||||
def test_cannot_instantiate_abc_directly():
|
||||
with pytest.raises(TypeError):
|
||||
RegistryABC()
|
||||
0
test/core/environment/__init__.py
Normal file
0
test/core/environment/__init__.py
Normal file
92
test/core/environment/environment_test.py
Normal file
92
test/core/environment/environment_test.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import os
|
||||
import pytest
|
||||
from cpl.core.environment.environment import Environment
|
||||
from cpl.core.environment.environment_enum import EnvironmentEnum
|
||||
|
||||
|
||||
def test_set_and_get_environment_valid():
|
||||
for env in EnvironmentEnum:
|
||||
Environment.set_environment(env.value)
|
||||
assert Environment.get_environment() == env.value
|
||||
|
||||
|
||||
def test_set_environment_invalid():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set_environment("invalid_env")
|
||||
|
||||
|
||||
def test_set_environment_empty():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set_environment("")
|
||||
|
||||
|
||||
def test_set_environment_none():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set_environment(None)
|
||||
|
||||
|
||||
def test_set_and_get_app_name():
|
||||
Environment.set_app_name("TestApp")
|
||||
assert Environment.get_app_name() == "TestApp"
|
||||
|
||||
|
||||
def test_get_host_name():
|
||||
hostname = Environment.get_host_name()
|
||||
assert isinstance(hostname, str)
|
||||
assert len(hostname) > 0
|
||||
|
||||
|
||||
def test_get_cwd():
|
||||
cwd = Environment.get_cwd()
|
||||
assert isinstance(cwd, str)
|
||||
assert os.path.isdir(cwd)
|
||||
|
||||
|
||||
def test_set_cwd(tmp_path):
|
||||
original = Environment.get_cwd()
|
||||
Environment.set_cwd(str(tmp_path))
|
||||
assert Environment.get_cwd() == str(tmp_path)
|
||||
Environment.set_cwd(original)
|
||||
|
||||
|
||||
def test_set_cwd_empty():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set_cwd("")
|
||||
|
||||
|
||||
def test_set_cwd_none():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set_cwd(None)
|
||||
|
||||
|
||||
def test_set_and_get_generic():
|
||||
Environment.set("CPL_TEST_KEY", "hello")
|
||||
assert Environment.get("CPL_TEST_KEY", str) == "hello"
|
||||
|
||||
|
||||
def test_get_missing_key_returns_default():
|
||||
result = Environment.get("CPL_NONEXISTENT_KEY_XYZ", str, "default_value")
|
||||
assert result == "default_value"
|
||||
|
||||
|
||||
def test_get_missing_key_returns_none():
|
||||
result = Environment.get("CPL_NONEXISTENT_KEY_XYZ2", str)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_set_key_empty():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set("", "value")
|
||||
|
||||
|
||||
def test_set_key_none():
|
||||
with pytest.raises(AssertionError):
|
||||
Environment.set(None, "value")
|
||||
|
||||
|
||||
def test_environment_enum_values():
|
||||
values = [e.value for e in EnvironmentEnum]
|
||||
assert "production" in values
|
||||
assert "staging" in values
|
||||
assert "testing" in values
|
||||
assert "development" in values
|
||||
0
test/core/pipes/__init__.py
Normal file
0
test/core/pipes/__init__.py
Normal file
36
test/core/pipes/bool_pipe_test.py
Normal file
36
test/core/pipes/bool_pipe_test.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import pytest
|
||||
from cpl.core.pipes.bool_pipe import BoolPipe
|
||||
|
||||
|
||||
def test_to_str_true():
|
||||
assert BoolPipe.to_str(True) == "true"
|
||||
|
||||
|
||||
def test_to_str_false():
|
||||
assert BoolPipe.to_str(False) == "false"
|
||||
|
||||
|
||||
def test_from_str_true_values():
|
||||
assert BoolPipe.from_str("True") is True
|
||||
assert BoolPipe.from_str("true") is True
|
||||
assert BoolPipe.from_str("1") is True
|
||||
assert BoolPipe.from_str("yes") is True
|
||||
assert BoolPipe.from_str("y") is True
|
||||
assert BoolPipe.from_str("Y") is True
|
||||
|
||||
|
||||
def test_from_str_false_values():
|
||||
assert BoolPipe.from_str("false") is False
|
||||
assert BoolPipe.from_str("False") is False
|
||||
assert BoolPipe.from_str("0") is False
|
||||
assert BoolPipe.from_str("no") is False
|
||||
assert BoolPipe.from_str("") is False
|
||||
assert BoolPipe.from_str("anything") is False
|
||||
|
||||
|
||||
def test_roundtrip_true():
|
||||
assert BoolPipe.from_str(BoolPipe.to_str(True)) is True
|
||||
|
||||
|
||||
def test_roundtrip_false():
|
||||
assert BoolPipe.from_str(BoolPipe.to_str(False)) is False
|
||||
59
test/core/pipes/ip_address_pipe_test.py
Normal file
59
test/core/pipes/ip_address_pipe_test.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from cpl.core.pipes.ip_address_pipe import IPAddressPipe
|
||||
|
||||
|
||||
def test_to_str_valid():
|
||||
assert IPAddressPipe.to_str([192, 168, 1, 1]) == "192.168.1.1"
|
||||
assert IPAddressPipe.to_str([0, 0, 0, 0]) == "0.0.0.0"
|
||||
assert IPAddressPipe.to_str([255, 255, 255, 255]) == "255.255.255.255"
|
||||
assert IPAddressPipe.to_str([127, 0, 0, 1]) == "127.0.0.1"
|
||||
|
||||
|
||||
def test_to_str_too_few_parts():
|
||||
with pytest.raises(ValueError):
|
||||
IPAddressPipe.to_str([192, 168, 1])
|
||||
|
||||
|
||||
def test_to_str_too_many_parts():
|
||||
with pytest.raises(ValueError):
|
||||
IPAddressPipe.to_str([192, 168, 1, 1, 5])
|
||||
|
||||
|
||||
def test_to_str_byte_out_of_range():
|
||||
with pytest.raises(ValueError):
|
||||
IPAddressPipe.to_str([256, 0, 0, 0])
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
IPAddressPipe.to_str([-1, 0, 0, 0])
|
||||
|
||||
|
||||
def test_from_str_valid():
|
||||
assert IPAddressPipe.from_str("192.168.1.1") == [192, 168, 1, 1]
|
||||
assert IPAddressPipe.from_str("0.0.0.0") == [0, 0, 0, 0]
|
||||
assert IPAddressPipe.from_str("255.255.255.255") == [255, 255, 255, 255]
|
||||
assert IPAddressPipe.from_str("127.0.0.1") == [127, 0, 0, 1]
|
||||
|
||||
|
||||
def test_from_str_too_few_parts():
|
||||
with pytest.raises(Exception):
|
||||
IPAddressPipe.from_str("192.168.1")
|
||||
|
||||
|
||||
def test_from_str_too_many_parts():
|
||||
with pytest.raises(Exception):
|
||||
IPAddressPipe.from_str("192.168.1.1.5")
|
||||
|
||||
|
||||
def test_from_str_byte_out_of_range():
|
||||
with pytest.raises(Exception):
|
||||
IPAddressPipe.from_str("256.0.0.0")
|
||||
|
||||
|
||||
def test_from_str_invalid_format():
|
||||
with pytest.raises(Exception):
|
||||
IPAddressPipe.from_str("not.an.ip.addr")
|
||||
|
||||
|
||||
def test_roundtrip():
|
||||
original = [10, 20, 30, 40]
|
||||
assert IPAddressPipe.from_str(IPAddressPipe.to_str(original)) == original
|
||||
35
test/core/property_test.py
Normal file
35
test/core/property_test.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from cpl.core.property import classproperty
|
||||
|
||||
|
||||
class MyClass:
|
||||
_value = 42
|
||||
|
||||
@classproperty
|
||||
def value(cls):
|
||||
return cls._value
|
||||
|
||||
@classproperty
|
||||
def name(cls):
|
||||
return cls.__name__
|
||||
|
||||
|
||||
class Child(MyClass):
|
||||
_value = 99
|
||||
|
||||
|
||||
def test_classproperty_on_class():
|
||||
assert MyClass.value == 42
|
||||
|
||||
|
||||
def test_classproperty_on_instance():
|
||||
obj = MyClass()
|
||||
assert obj.value == 42
|
||||
|
||||
|
||||
def test_classproperty_subclass_inherits_override():
|
||||
assert Child.value == 99
|
||||
|
||||
|
||||
def test_classproperty_returns_class_name():
|
||||
assert MyClass.name == "MyClass"
|
||||
assert Child.name == "Child"
|
||||
0
test/core/time/__init__.py
Normal file
0
test/core/time/__init__.py
Normal file
60
test/core/time/cron_test.py
Normal file
60
test/core/time/cron_test.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
from cpl.core.time.cron import Cron
|
||||
|
||||
|
||||
def test_next_returns_datetime():
|
||||
cron = Cron("* * * * *")
|
||||
result = cron.next()
|
||||
assert isinstance(result, datetime)
|
||||
|
||||
|
||||
def test_next_is_in_the_future():
|
||||
cron = Cron("* * * * *")
|
||||
result = cron.next()
|
||||
assert result > datetime.now()
|
||||
|
||||
|
||||
def test_next_called_multiple_times_is_monotonic():
|
||||
cron = Cron("* * * * *")
|
||||
results = [cron.next() for _ in range(5)]
|
||||
for i in range(1, len(results)):
|
||||
assert results[i] > results[i - 1]
|
||||
|
||||
|
||||
def test_every_minute_interval():
|
||||
start = datetime(2024, 1, 1, 12, 0, 0)
|
||||
cron = Cron("* * * * *", start_time=start)
|
||||
first = cron.next()
|
||||
second = cron.next()
|
||||
diff = (second - first).total_seconds()
|
||||
assert diff == 60
|
||||
|
||||
|
||||
def test_hourly_interval():
|
||||
start = datetime(2024, 1, 1, 12, 0, 0)
|
||||
cron = Cron("0 * * * *", start_time=start)
|
||||
first = cron.next()
|
||||
second = cron.next()
|
||||
diff = (second - first).total_seconds()
|
||||
assert diff == 3600
|
||||
|
||||
|
||||
def test_daily_midnight():
|
||||
start = datetime(2024, 1, 1, 0, 0, 0)
|
||||
cron = Cron("0 0 * * *", start_time=start)
|
||||
first = cron.next()
|
||||
assert first.hour == 0
|
||||
assert first.minute == 0
|
||||
|
||||
|
||||
def test_custom_start_time():
|
||||
start = datetime(2024, 6, 15, 10, 30, 0)
|
||||
cron = Cron("*/5 * * * *", start_time=start)
|
||||
result = cron.next()
|
||||
assert result > start
|
||||
|
||||
|
||||
def test_invalid_cron_expression():
|
||||
with pytest.raises(Exception):
|
||||
Cron("invalid expression")
|
||||
125
test/core/utils/cache_test.py
Normal file
125
test/core/utils/cache_test.py
Normal file
@@ -0,0 +1,125 @@
|
||||
import time
|
||||
import pytest
|
||||
from cpl.core.utils.cache import Cache
|
||||
|
||||
|
||||
def test_set_and_get():
|
||||
cache = Cache()
|
||||
cache.set("key1", "value1")
|
||||
assert cache.get("key1") == "value1"
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_get_missing_key_returns_none():
|
||||
cache = Cache()
|
||||
assert cache.get("nonexistent") is None
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_has_existing_key():
|
||||
cache = Cache()
|
||||
cache.set("key", "val")
|
||||
assert cache.has("key") is True
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_has_missing_key():
|
||||
cache = Cache()
|
||||
assert cache.has("missing") is False
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_delete():
|
||||
cache = Cache()
|
||||
cache.set("key", "val")
|
||||
cache.delete("key")
|
||||
assert cache.get("key") is None
|
||||
assert cache.has("key") is False
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_clear():
|
||||
cache = Cache()
|
||||
cache.set("a", 1)
|
||||
cache.set("b", 2)
|
||||
cache.clear()
|
||||
assert cache.get("a") is None
|
||||
assert cache.get("b") is None
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_get_all():
|
||||
cache = Cache()
|
||||
cache.set("x", 10)
|
||||
cache.set("y", 20)
|
||||
values = cache.get_all()
|
||||
assert set(values) == {10, 20}
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_get_all_empty():
|
||||
cache = Cache()
|
||||
assert cache.get_all() == []
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_ttl_expiry():
|
||||
cache = Cache()
|
||||
cache.set("temp", "data", ttl=1)
|
||||
assert cache.get("temp") == "data"
|
||||
time.sleep(1.1)
|
||||
assert cache.get("temp") is None
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_has_after_ttl_expiry():
|
||||
cache = Cache()
|
||||
cache.set("temp", "data", ttl=1)
|
||||
time.sleep(1.1)
|
||||
assert cache.has("temp") is False
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_default_ttl():
|
||||
cache = Cache(default_ttl=1)
|
||||
cache.set("key", "value")
|
||||
assert cache.get("key") == "value"
|
||||
time.sleep(1.1)
|
||||
assert cache.get("key") is None
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_per_key_ttl_overrides_default():
|
||||
cache = Cache(default_ttl=60)
|
||||
cache.set("short", "val", ttl=1)
|
||||
time.sleep(1.1)
|
||||
assert cache.get("short") is None
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_get_all_excludes_expired():
|
||||
cache = Cache()
|
||||
cache.set("permanent", "keep")
|
||||
cache.set("temporary", "gone", ttl=1)
|
||||
time.sleep(1.1)
|
||||
values = cache.get_all()
|
||||
assert "keep" in values
|
||||
assert "gone" not in values
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_overwrite_key():
|
||||
cache = Cache()
|
||||
cache.set("key", "old")
|
||||
cache.set("key", "new")
|
||||
assert cache.get("key") == "new"
|
||||
cache.stop()
|
||||
|
||||
|
||||
def test_cleanup_removes_expired():
|
||||
cache = Cache(cleanup_interval=9999)
|
||||
cache.set("exp", "x", ttl=1)
|
||||
time.sleep(1.1)
|
||||
cache.cleanup()
|
||||
assert cache.get("exp") is None
|
||||
cache.stop()
|
||||
64
test/core/utils/get_value_test.py
Normal file
64
test/core/utils/get_value_test.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import pytest
|
||||
from typing import List
|
||||
from cpl.core.utils.get_value import get_value
|
||||
|
||||
|
||||
def test_get_existing_str():
|
||||
assert get_value({"key": "hello"}, "key", str) == "hello"
|
||||
|
||||
|
||||
def test_get_existing_int_already_typed():
|
||||
# Value already has the correct type -> returned directly
|
||||
assert get_value({"count": 42}, "count", int) == 42
|
||||
|
||||
|
||||
def test_get_existing_float_already_typed():
|
||||
# Value already has the correct type -> returned directly
|
||||
assert get_value({"pi": 3.14}, "pi", float) == 3.14
|
||||
|
||||
|
||||
# NOTE: get_value calls cast() internally but does NOT return the cast result
|
||||
# (the return value is lost). String "42" -> int returns None instead of 42.
|
||||
# This is a known bug in get_value.
|
||||
def test_get_str_to_int_returns_none_bug():
|
||||
result = get_value({"count": "42"}, "count", int)
|
||||
assert result is None # Bug: should be 42
|
||||
|
||||
|
||||
def test_get_missing_key_returns_none():
|
||||
assert get_value({}, "missing", str) is None
|
||||
|
||||
|
||||
def test_get_missing_key_returns_default():
|
||||
assert get_value({}, "missing", str, "fallback") == "fallback"
|
||||
|
||||
|
||||
def test_get_value_already_correct_type():
|
||||
assert get_value({"x": 5}, "x", int) == 5
|
||||
|
||||
|
||||
def test_get_list_already_correct_type():
|
||||
result = get_value({"items": [1, 2, 3]}, "items", List[int])
|
||||
assert result == [1, 2, 3]
|
||||
|
||||
|
||||
def test_get_list_of_str_already_correct_type():
|
||||
result = get_value({"tags": ["a", "b"]}, "tags", List[str])
|
||||
assert result == ["a", "b"]
|
||||
|
||||
|
||||
def test_get_bool_already_typed():
|
||||
# Value is already bool -> returned directly
|
||||
assert get_value({"flag": True}, "flag", bool) is True
|
||||
assert get_value({"flag": False}, "flag", bool) is False
|
||||
|
||||
|
||||
# NOTE: Same bug – string "true" -> bool returns None
|
||||
def test_get_bool_from_str_returns_none_bug():
|
||||
result = get_value({"flag": "true"}, "flag", bool)
|
||||
assert result is None # Bug: should be True
|
||||
|
||||
|
||||
def test_cast_failure_returns_default():
|
||||
result = get_value({"val": "not_a_number"}, "val", int, -1)
|
||||
assert result == -1
|
||||
68
test/core/utils/json_processor_test.py
Normal file
68
test/core/utils/json_processor_test.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import pytest
|
||||
from enum import Enum
|
||||
from cpl.core.utils.json_processor import JSONProcessor
|
||||
|
||||
|
||||
class Color(Enum):
|
||||
RED = "RED"
|
||||
GREEN = "GREEN"
|
||||
|
||||
|
||||
class Point:
|
||||
def __init__(self, x: int, y: int):
|
||||
self.x = x
|
||||
self.y = y
|
||||
|
||||
|
||||
class Named:
|
||||
def __init__(self, name: str, value: int = 0):
|
||||
self.name = name
|
||||
self.value = value
|
||||
|
||||
|
||||
class Nested:
|
||||
def __init__(self, point: Point, label: str):
|
||||
self.point = point
|
||||
self.label = label
|
||||
|
||||
|
||||
class WithEnum:
|
||||
def __init__(self, color: Color):
|
||||
self.color = color
|
||||
|
||||
|
||||
def test_simple_object():
|
||||
obj = JSONProcessor.process(Point, {"X": 3, "Y": 7})
|
||||
assert obj.x == 3
|
||||
assert obj.y == 7
|
||||
|
||||
|
||||
def test_camel_case_keys():
|
||||
obj = JSONProcessor.process(Named, {"Name": "Alice", "Value": 42})
|
||||
assert obj.name == "Alice"
|
||||
assert obj.value == 42
|
||||
|
||||
|
||||
def test_default_value_used():
|
||||
obj = JSONProcessor.process(Named, {"Name": "Bob"})
|
||||
assert obj.name == "Bob"
|
||||
assert obj.value == 0
|
||||
|
||||
|
||||
def test_none_for_missing_required():
|
||||
obj = JSONProcessor.process(Named, {})
|
||||
assert obj.name is None
|
||||
|
||||
|
||||
def test_nested_object():
|
||||
data = {"Point": {"X": 1, "Y": 2}, "Label": "origin"}
|
||||
obj = JSONProcessor.process(Nested, data)
|
||||
assert isinstance(obj.point, Point)
|
||||
assert obj.point.x == 1
|
||||
assert obj.point.y == 2
|
||||
assert obj.label == "origin"
|
||||
|
||||
|
||||
def test_enum_value():
|
||||
obj = JSONProcessor.process(WithEnum, {"Color": "RED"})
|
||||
assert obj.color == Color.RED
|
||||
Reference in New Issue
Block a user