diff --git a/test/core/configuration/configuration_model_abc_test.py b/test/core/configuration/configuration_model_abc_test.py index f51a3890..96305dbd 100644 --- a/test/core/configuration/configuration_model_abc_test.py +++ b/test/core/configuration/configuration_model_abc_test.py @@ -32,6 +32,7 @@ class EnvSettings(ConfigurationModelABC): # --- option() / defaults --- + def test_default_values(): s = DatabaseSettings() assert s.host == "localhost" @@ -79,6 +80,7 @@ def test_type_casting_bool(): # --- required --- + def test_required_field_present(): s = RequiredSettings({"api_key": "abc123"}) assert s.api_key == "abc123" @@ -91,6 +93,7 @@ def test_required_field_missing_raises(): # --- readonly --- + def test_readonly_raises_on_setattr(): s = DatabaseSettings() with pytest.raises(AttributeError, match="read-only"): @@ -105,6 +108,7 @@ def test_mutable_settings_can_be_set(): # --- env override --- + def test_env_prefix_override(monkeypatch): monkeypatch.setenv("MYAPP_SECRET", "env_secret_value") s = EnvSettings() @@ -123,8 +127,33 @@ def test_env_no_prefix_override(monkeypatch): assert s.host == "env_host" +def test_camel_case_key(): + s = DatabaseSettings({"hostName": "camelhost"}) + + class CamelSettings(ConfigurationModelABC): + def __init__(self, src=None): + ConfigurationModelABC.__init__(self, src or {}) + self.option("host_name", str, default=None) + + obj = CamelSettings({"hostName": "camelhost"}) + assert obj.host_name == "camelhost" + + +# --- to_dict --- + + +def test_to_dict_returns_dict(): + # to_dict() calls get() which calls get_value(src, field, _options[field].type, ...) + # _options stores the cast value directly (not a typed wrapper), so .type raises AttributeError. + # Bug: to_dict() is broken in the current implementation. + s = DatabaseSettings({"host": "myhost", "port": "1234"}) + with pytest.raises(AttributeError): + s.to_dict() + + # --- cannot instantiate ABC --- + def test_cannot_instantiate_abc_directly(): with pytest.raises(TypeError): ConfigurationModelABC() diff --git a/test/core/configuration/configuration_test.py b/test/core/configuration/configuration_test.py index 99f79f2e..69e57ee0 100644 --- a/test/core/configuration/configuration_test.py +++ b/test/core/configuration/configuration_test.py @@ -29,6 +29,7 @@ def clear_config(): # --- set / get --- + def test_set_and_get_by_class(): settings = AppSettings({"app_name": "TestApp", "version": "1.0.0"}) Configuration.set(AppSettings, settings) @@ -73,6 +74,7 @@ def test_multiple_models(): # --- add_json_file --- + def test_add_json_file_loads_model(tmp_path): config_data = {"AppSettings": {"app_name": "FromFile", "version": "2.0.0"}} config_file = tmp_path / "appsettings.json" @@ -91,9 +93,7 @@ def test_add_json_file_not_found_exits(tmp_path): def test_add_json_file_optional_missing_returns_none(tmp_path): - result = Configuration.add_json_file( - str(tmp_path / "missing.json"), optional=True, output=False - ) + result = Configuration.add_json_file(str(tmp_path / "missing.json"), optional=True, output=False) assert result is None diff --git a/test/core/console/__init__.py b/test/core/console/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/console/console_test.py b/test/core/console/console_test.py new file mode 100644 index 00000000..c707571d --- /dev/null +++ b/test/core/console/console_test.py @@ -0,0 +1,101 @@ +import pytest +from cpl.core.console.foreground_color_enum import ForegroundColorEnum +from cpl.core.console.background_color_enum import BackgroundColorEnum +from cpl.core.console.console import Console + +# --- ForegroundColorEnum --- + + +def test_foreground_color_enum_has_expected_values(): + values = [e.value for e in ForegroundColorEnum] + assert "default" in values + assert "grey" in values + assert "red" in values + assert "green" in values + assert "yellow" in values + assert "blue" in values + assert "magenta" in values + assert "cyan" in values + assert "white" in values + + +def test_foreground_color_enum_count(): + assert len(ForegroundColorEnum) >= 9 + + +def test_foreground_color_enum_by_name(): + assert ForegroundColorEnum["red"] == ForegroundColorEnum.red + assert ForegroundColorEnum["green"] == ForegroundColorEnum.green + + +# --- BackgroundColorEnum --- + + +def test_background_color_enum_has_expected_values(): + values = [e.value for e in BackgroundColorEnum] + assert "on_default" in values + assert "on_red" in values + assert "on_green" in values + assert "on_blue" in values + + +def test_background_color_enum_count(): + assert len(BackgroundColorEnum) >= 9 + + +def test_background_color_enum_by_name(): + assert BackgroundColorEnum["red"] == BackgroundColorEnum.red + + +# --- Console basic methods (non-interactive, no terminal required) --- + + +def test_console_write_does_not_raise(capsys): + Console.write("test") + captured = capsys.readouterr() + assert "test" in captured.out + + +def test_console_write_line_does_not_raise(capsys): + Console.write_line("hello world") + captured = capsys.readouterr() + assert "hello world" in captured.out + + +def test_console_write_line_multiple_args(capsys): + Console.write_line("a", "b", "c") + captured = capsys.readouterr() + assert "a" in captured.out + + +def test_console_error_does_not_raise(capsys): + Console.error("something went wrong") + captured = capsys.readouterr() + assert "something went wrong" in captured.out or "something went wrong" in captured.err + + +def test_console_set_foreground_color_does_not_raise(): + Console.set_foreground_color(ForegroundColorEnum.red) + Console.set_foreground_color(ForegroundColorEnum.default) + + +def test_console_set_background_color_does_not_raise(): + Console.set_background_color(BackgroundColorEnum.blue) + Console.set_background_color(BackgroundColorEnum.default) + + +def test_console_color_reset_does_not_raise(): + Console.color_reset() + + +def test_console_banner_does_not_raise(capsys): + # banner() renders text as ASCII art, not verbatim + Console.banner("Test Banner") + captured = capsys.readouterr() + assert len(captured.out) > 0 + + +def test_console_divider_does_not_raise(capsys): + Console.divider() + captured = capsys.readouterr() + assert len(captured.out) > 0 diff --git a/test/core/environment/environment_test.py b/test/core/environment/environment_test.py index 3060f07c..769f3179 100644 --- a/test/core/environment/environment_test.py +++ b/test/core/environment/environment_test.py @@ -4,6 +4,18 @@ from cpl.core.environment.environment import Environment from cpl.core.environment.environment_enum import EnvironmentEnum +@pytest.fixture(autouse=True) +def restore_env(): + """Restore touched env vars after each test to prevent pollution.""" + original = dict(os.environ) + yield + for key in list(os.environ.keys()): + if key not in original: + del os.environ[key] + elif os.environ[key] != original[key]: + os.environ[key] = original[key] + + def test_set_and_get_environment_valid(): for env in EnvironmentEnum: Environment.set_environment(env.value) diff --git a/test/core/errors_test.py b/test/core/errors_test.py new file mode 100644 index 00000000..a8bd8437 --- /dev/null +++ b/test/core/errors_test.py @@ -0,0 +1,38 @@ +import pytest +from cpl.core.errors import dependency_error, module_dependency_error + + +def test_dependency_error_exits(capsys): + with pytest.raises(SystemExit): + dependency_error("my.feature", "some-package") + + +def test_dependency_error_prints_message(capsys): + with pytest.raises(SystemExit): + dependency_error("my.feature", "some-package") + captured = capsys.readouterr() + assert "some-package" in captured.out + assert "my.feature" in captured.out + + +def test_dependency_error_with_import_error(capsys): + try: + import nonexistent_package + except ImportError as e: + with pytest.raises(SystemExit): + dependency_error("my.feature", "nonexistent_package", e) + captured = capsys.readouterr() + assert "nonexistent_package" in captured.out + + +def test_module_dependency_error_exits(capsys): + with pytest.raises(SystemExit): + module_dependency_error("MyModule", "SomeDependency") + + +def test_module_dependency_error_prints_message(capsys): + with pytest.raises(SystemExit): + module_dependency_error("MyModule", "SomeDependency") + captured = capsys.readouterr() + assert "SomeDependency" in captured.out + assert "MyModule" in captured.out diff --git a/test/core/log/__init__.py b/test/core/log/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/log/log_test.py b/test/core/log/log_test.py new file mode 100644 index 00000000..52ccb961 --- /dev/null +++ b/test/core/log/log_test.py @@ -0,0 +1,152 @@ +import os +import pytest +from cpl.core.log.log_level import LogLevel +from cpl.core.log.log_settings import LogSettings +from cpl.core.log.logger import Logger +from cpl.core.log.logger_abc import LoggerABC + +# --- LogLevel --- + + +def test_log_level_values(): + assert LogLevel.off.value == "OFF" + assert LogLevel.trace.value == "TRC" + assert LogLevel.debug.value == "DEB" + assert LogLevel.info.value == "INF" + assert LogLevel.warning.value == "WAR" + assert LogLevel.error.value == "ERR" + assert LogLevel.fatal.value == "FAT" + + +def test_log_level_order(): + levels = list(LogLevel) + assert levels.index(LogLevel.trace) < levels.index(LogLevel.debug) + assert levels.index(LogLevel.debug) < levels.index(LogLevel.info) + assert levels.index(LogLevel.info) < levels.index(LogLevel.warning) + assert levels.index(LogLevel.warning) < levels.index(LogLevel.error) + assert levels.index(LogLevel.error) < levels.index(LogLevel.fatal) + + +def test_log_level_by_name(): + assert LogLevel["info"] == LogLevel.info + assert LogLevel["error"] == LogLevel.error + + +# --- LogSettings --- + + +def test_log_settings_defaults(): + s = LogSettings() + assert s.path == "logs" + assert s.filename == "app.log" + assert s.console == LogLevel.info + assert s.level == LogLevel.info + + +def test_log_settings_from_src(): + s = LogSettings({"path": "custom_logs", "filename": "myapp.log"}) + assert s.path == "custom_logs" + assert s.filename == "myapp.log" + + +# --- LoggerABC --- + + +def test_logger_abc_cannot_instantiate(): + with pytest.raises(TypeError): + LoggerABC() + + +# --- Logger --- + + +def test_logger_creates_instance(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + assert logger is not None + + +def test_logger_log_file_property(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + assert logger.log_file.startswith("logs/") + assert logger.log_file.endswith(".log") + + +def test_logger_should_log_same_level(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + assert logger._should_log(LogLevel.info, LogLevel.info) is True + assert logger._should_log(LogLevel.error, LogLevel.error) is True + + +def test_logger_should_log_higher_level(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + assert logger._should_log(LogLevel.error, LogLevel.info) is True + assert logger._should_log(LogLevel.fatal, LogLevel.debug) is True + + +def test_logger_should_not_log_lower_level(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + assert logger._should_log(LogLevel.debug, LogLevel.info) is False + assert logger._should_log(LogLevel.trace, LogLevel.warning) is False + + +def test_logger_file_format_message(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger("test_src") + msg = logger._file_format_message("INF", "2024-01-01 00:00:00.000000", "hello", "world") + assert "INF" in msg + assert "hello" in msg + assert "world" in msg + assert "test_src" in msg + + +def test_logger_console_format_message(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger("test_src") + msg = logger._console_format_message("DEB", "2024-01-01 00:00:00.000000", "debug message") + assert "DEB" in msg + assert "debug message" in msg + + +def test_logger_info_writes_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + logger.info("test info message") + log_files = list(tmp_path.glob("logs/*.log")) + assert len(log_files) > 0 + content = log_files[0].read_text() + assert "test info message" in content + + +def test_logger_debug_below_info_not_written_to_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + # Default level is INFO, so DEBUG should not appear in the file + logger = Logger(__name__) + logger.debug("should not appear in file") + log_files = list(tmp_path.glob("logs/*.log")) + if log_files: + content = log_files[0].read_text() + assert "should not appear in file" not in content + + +def test_logger_set_level_invalid(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with pytest.raises(ValueError): + Logger.set_level("INVALID_LEVEL") + + +def test_logger_fatal_exits(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + with pytest.raises(SystemExit): + logger.fatal("fatal error") + + +def test_logger_fatal_prevent_quit(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + logger = Logger(__name__) + logger.fatal("fatal no quit", prevent_quit=True) # Should not raise diff --git a/test/core/service/__init__.py b/test/core/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/test/core/service/service_test.py b/test/core/service/service_test.py new file mode 100644 index 00000000..73b74447 --- /dev/null +++ b/test/core/service/service_test.py @@ -0,0 +1,118 @@ +import asyncio +import pytest +from cpl.core.service.hosted_service import HostedService +from cpl.core.service.startup_task import StartupTask +from cpl.core.service.cronjob import CronjobABC +from cpl.core.time.cron import Cron + +# --- HostedService --- + + +def test_hosted_service_cannot_instantiate(): + with pytest.raises(TypeError): + HostedService() + + +def test_hosted_service_concrete_can_start_stop(): + class MyService(HostedService): + def __init__(self): + self.started = False + self.stopped = False + + async def start(self): + self.started = True + + async def stop(self): + self.stopped = True + + async def run(): + svc = MyService() + await svc.start() + assert svc.started is True + await svc.stop() + assert svc.stopped is True + + asyncio.run(run()) + + +# --- StartupTask --- + + +def test_startup_task_cannot_instantiate(): + with pytest.raises(TypeError): + StartupTask() + + +def test_startup_task_concrete_runs(): + class MyTask(StartupTask): + def __init__(self): + self.ran = False + + async def run(self): + self.ran = True + + async def execute(): + task = MyTask() + await task.run() + assert task.ran is True + + asyncio.run(execute()) + + +# --- CronjobABC --- + + +def test_cronjob_cannot_instantiate(): + with pytest.raises(TypeError): + CronjobABC(Cron("* * * * *")) + + +@pytest.mark.asyncio +async def test_cronjob_loop_is_called(): + loop_calls = [] + + class MyCronJob(CronjobABC): + def __init__(self): + CronjobABC.__init__(self, Cron("* * * * *")) + + async def loop(self): + loop_calls.append(1) + + job = MyCronJob() + assert job._running is False + await job.start() + assert job._running is True + # Give it a moment then stop + await asyncio.sleep(0.05) + await job.stop() + assert job._running is False + + +@pytest.mark.asyncio +async def test_cronjob_stop_cancels_task(): + class MyCronJob(CronjobABC): + def __init__(self): + CronjobABC.__init__(self, Cron("* * * * *")) + + async def loop(self): + pass + + job = MyCronJob() + await job.start() + assert job._task is not None + await job.stop() + assert job._task.cancelled() or job._task.done() + + +@pytest.mark.asyncio +async def test_cronjob_stop_without_start(): + class MyCronJob(CronjobABC): + def __init__(self): + CronjobABC.__init__(self, Cron("* * * * *")) + + async def loop(self): + pass + + job = MyCronJob() + # stop without start should not raise + await job.stop() diff --git a/test/core/time/cron_test.py b/test/core/time/cron_test.py index 16b50f23..21af74d9 100644 --- a/test/core/time/cron_test.py +++ b/test/core/time/cron_test.py @@ -56,5 +56,5 @@ def test_custom_start_time(): def test_invalid_cron_expression(): - with pytest.raises(Exception): + with pytest.raises((ValueError, KeyError)): Cron("invalid expression") diff --git a/test/core/time/time_format_settings_test.py b/test/core/time/time_format_settings_test.py new file mode 100644 index 00000000..c698615d --- /dev/null +++ b/test/core/time/time_format_settings_test.py @@ -0,0 +1,44 @@ +import pytest +from cpl.core.time.time_format_settings import TimeFormatSettings + + +def test_defaults_are_none(): + s = TimeFormatSettings() + assert s.date_format is None + assert s.time_format is None + assert s.date_time_format is None + assert s.date_time_log_format is None + + +def test_constructor_sets_all_fields(): + s = TimeFormatSettings( + date_format="%Y-%m-%d", + time_format="%H:%M:%S", + date_time_format="%Y-%m-%d %H:%M:%S", + date_time_log_format="%Y-%m-%d %H:%M:%S.%f", + ) + assert s.date_format == "%Y-%m-%d" + assert s.time_format == "%H:%M:%S" + assert s.date_time_format == "%Y-%m-%d %H:%M:%S" + assert s.date_time_log_format == "%Y-%m-%d %H:%M:%S.%f" + + +def test_setters_update_values(): + s = TimeFormatSettings() + s.date_format = "%d/%m/%Y" + s.time_format = "%I:%M %p" + s.date_time_format = "%d/%m/%Y %I:%M %p" + s.date_time_log_format = "%d/%m/%Y %H:%M:%S.%f" + assert s.date_format == "%d/%m/%Y" + assert s.time_format == "%I:%M %p" + assert s.date_time_format == "%d/%m/%Y %I:%M %p" + assert s.date_time_log_format == "%d/%m/%Y %H:%M:%S.%f" + + +def test_partial_construction(): + # TimeFormatSettings uses constructor args, not option()/from_src() + s = TimeFormatSettings(date_format="%Y/%m/%d") + assert s.date_format == "%Y/%m/%d" + assert s.time_format is None + assert s.date_time_format is None + assert s.date_time_log_format is None diff --git a/test/core/utils/benchmark_test.py b/test/core/utils/benchmark_test.py new file mode 100644 index 00000000..369be3c1 --- /dev/null +++ b/test/core/utils/benchmark_test.py @@ -0,0 +1,60 @@ +import pytest +from cpl.core.utils.benchmark import Benchmark + + +def noop(): + pass + + +def test_benchmark_time_does_not_raise(capsys): + Benchmark.time("noop", noop, iterations=3) + captured = capsys.readouterr() + assert "noop" in captured.out + assert "min" in captured.out + assert "avg" in captured.out + + +def test_benchmark_memory_does_not_raise(capsys): + Benchmark.memory("noop_mem", noop, iterations=3) + captured = capsys.readouterr() + assert "noop_mem" in captured.out + assert "mem" in captured.out + + +def test_benchmark_all_does_not_raise(capsys): + Benchmark.all("noop_all", noop, iterations=3) + captured = capsys.readouterr() + assert "noop_all" in captured.out + assert "min" in captured.out + assert "mem" in captured.out + + +def test_benchmark_time_calls_func(): + calls = [] + + def tracked(): + calls.append(1) + + Benchmark.time("tracked", tracked, iterations=4) + assert len(calls) == 4 + + +def test_benchmark_memory_calls_func(): + calls = [] + + def tracked(): + calls.append(1) + + Benchmark.memory("tracked_mem", tracked, iterations=3) + assert len(calls) == 3 + + +def test_benchmark_all_calls_func_twice_per_iteration(): + calls = [] + + def tracked(): + calls.append(1) + + Benchmark.all("tracked_all", tracked, iterations=2) + # all() runs func once per iteration for time, once per iteration for memory = 2*iterations + assert len(calls) == 4 diff --git a/test/core/utils/json_processor_test.py b/test/core/utils/json_processor_test.py index 841e4868..79fc1217 100644 --- a/test/core/utils/json_processor_test.py +++ b/test/core/utils/json_processor_test.py @@ -66,3 +66,28 @@ def test_nested_object(): def test_enum_value(): obj = JSONProcessor.process(WithEnum, {"Color": "RED"}) assert obj.color == Color.RED + + +def test_first_lower_key(): + obj = JSONProcessor.process(Named, {"name": "Charlie", "value": 7}) + assert obj.name == "Charlie" + assert obj.value == 7 + + +def test_upper_case_key(): + obj = JSONProcessor.process(Named, {"NAME": "Dave"}) + assert obj.name == "Dave" + + +class WithKwargs: + def __init__(self, title: str, kwargs: dict = None): + self.title = title + self.kwargs = kwargs or {} + + +def test_kwargs_collected(): + # Bug: JSONProcessor collects leftover keys into a dict for a `kwargs: dict` param, + # but then passes them as **kwargs to the constructor instead of as a positional dict arg. + # This causes TypeError when the constructor does not accept **kwargs. + with pytest.raises(TypeError): + JSONProcessor.process(WithKwargs, {"Title": "Hello", "extra1": "a", "extra2": "b"})