From ca58f636ee906089299f4c10851be95003e3176b Mon Sep 17 00:00:00 2001 From: clu Date: Mon, 13 Apr 2026 18:40:01 +0200 Subject: [PATCH] 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 --- test/core/abc/__init__.py | 0 test/core/abc/registry_abc_test.py | 66 ++++++++++++ test/core/environment/__init__.py | 0 test/core/environment/environment_test.py | 92 ++++++++++++++++ test/core/pipes/__init__.py | 0 test/core/pipes/bool_pipe_test.py | 36 +++++++ test/core/pipes/ip_address_pipe_test.py | 59 ++++++++++ test/core/property_test.py | 35 ++++++ test/core/time/__init__.py | 0 test/core/time/cron_test.py | 60 +++++++++++ test/core/utils/cache_test.py | 125 ++++++++++++++++++++++ test/core/utils/get_value_test.py | 64 +++++++++++ test/core/utils/json_processor_test.py | 68 ++++++++++++ 13 files changed, 605 insertions(+) create mode 100644 test/core/abc/__init__.py create mode 100644 test/core/abc/registry_abc_test.py create mode 100644 test/core/environment/__init__.py create mode 100644 test/core/environment/environment_test.py create mode 100644 test/core/pipes/__init__.py create mode 100644 test/core/pipes/bool_pipe_test.py create mode 100644 test/core/pipes/ip_address_pipe_test.py create mode 100644 test/core/property_test.py create mode 100644 test/core/time/__init__.py create mode 100644 test/core/time/cron_test.py create mode 100644 test/core/utils/cache_test.py create mode 100644 test/core/utils/get_value_test.py create mode 100644 test/core/utils/json_processor_test.py diff --git a/test/core/abc/__init__.py b/test/core/abc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/abc/registry_abc_test.py b/test/core/abc/registry_abc_test.py new file mode 100644 index 00000000..863906d4 --- /dev/null +++ b/test/core/abc/registry_abc_test.py @@ -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() diff --git a/test/core/environment/__init__.py b/test/core/environment/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/environment/environment_test.py b/test/core/environment/environment_test.py new file mode 100644 index 00000000..3060f07c --- /dev/null +++ b/test/core/environment/environment_test.py @@ -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 diff --git a/test/core/pipes/__init__.py b/test/core/pipes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/pipes/bool_pipe_test.py b/test/core/pipes/bool_pipe_test.py new file mode 100644 index 00000000..1f78d330 --- /dev/null +++ b/test/core/pipes/bool_pipe_test.py @@ -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 diff --git a/test/core/pipes/ip_address_pipe_test.py b/test/core/pipes/ip_address_pipe_test.py new file mode 100644 index 00000000..5331055d --- /dev/null +++ b/test/core/pipes/ip_address_pipe_test.py @@ -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 diff --git a/test/core/property_test.py b/test/core/property_test.py new file mode 100644 index 00000000..6b93a1ad --- /dev/null +++ b/test/core/property_test.py @@ -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" diff --git a/test/core/time/__init__.py b/test/core/time/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/time/cron_test.py b/test/core/time/cron_test.py new file mode 100644 index 00000000..16b50f23 --- /dev/null +++ b/test/core/time/cron_test.py @@ -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") diff --git a/test/core/utils/cache_test.py b/test/core/utils/cache_test.py new file mode 100644 index 00000000..2c888451 --- /dev/null +++ b/test/core/utils/cache_test.py @@ -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() diff --git a/test/core/utils/get_value_test.py b/test/core/utils/get_value_test.py new file mode 100644 index 00000000..f8977200 --- /dev/null +++ b/test/core/utils/get_value_test.py @@ -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 diff --git a/test/core/utils/json_processor_test.py b/test/core/utils/json_processor_test.py new file mode 100644 index 00000000..841e4868 --- /dev/null +++ b/test/core/utils/json_processor_test.py @@ -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