diff --git a/test/qlib/test_exceptions_log.py b/test/qlib/test_exceptions_log.py new file mode 100644 index 00000000..bd7ff21e --- /dev/null +++ b/test/qlib/test_exceptions_log.py @@ -0,0 +1,101 @@ +"""Tests for core/exception and log infrastructure.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +# ============================================================================= +# Exception hierarchy +# ============================================================================= + + +class TestExceptionHierarchy: + def test_workflow_error_is_exception(self): + from rdagent.core.exception import WorkflowError + with pytest.raises(WorkflowError): + raise WorkflowError("test") + + def test_format_error_is_workflow_error(self): + from rdagent.core.exception import FormatError, WorkflowError + assert issubclass(FormatError, WorkflowError) + + def test_coder_error_is_workflow_error(self): + from rdagent.core.exception import CoderError, WorkflowError + assert issubclass(CoderError, WorkflowError) + + def test_code_format_error_is_coder_error(self): + from rdagent.core.exception import CodeFormatError, CoderError + assert issubclass(CodeFormatError, CoderError) + + def test_custom_runtime_error_is_coder_error(self): + from rdagent.core.exception import CustomRuntimeError, CoderError + assert issubclass(CustomRuntimeError, CoderError) + + def test_no_output_error_is_coder_error(self): + from rdagent.core.exception import NoOutputError, CoderError + assert issubclass(NoOutputError, CoderError) + + def test_runner_error_is_exception(self): + from rdagent.core.exception import RunnerError + with pytest.raises(RunnerError): + raise RunnerError("test") + + def test_factor_empty_error_is_coder_error(self): + from rdagent.core.exception import FactorEmptyError, CoderError + assert FactorEmptyError is CoderError + + def test_model_empty_error_is_coder_error(self): + from rdagent.core.exception import ModelEmptyError, CoderError + assert ModelEmptyError is CoderError + + def test_llm_unavailable_error_is_runtime_error(self): + from rdagent.core.exception import LLMUnavailableError + with pytest.raises(LLMUnavailableError): + raise LLMUnavailableError("LLM down") + + def test_code_block_parse_error(self): + from rdagent.core.exception import CodeBlockParseError + e = CodeBlockParseError("msg", "content", "python") + assert e.message == "msg" + assert e.content == "content" + assert e.language == "python" + assert isinstance(e, Exception) + + def test_coder_error_caused_by_timeout_default(self): + from rdagent.core.exception import CoderError + assert CoderError.caused_by_timeout is False + + +# ============================================================================= +# RDAgentLog singleton +# ============================================================================= + + +class TestRDAgentLog: + def test_is_singleton(self): + from rdagent.log.logger import RDAgentLog + a = RDAgentLog() + b = RDAgentLog() + assert a is b + + def test_has_tag_context(self): + from rdagent.log.logger import RDAgentLog + assert hasattr(RDAgentLog, "_tag_ctx") + + +# ============================================================================= +# Daily log session +# ============================================================================= + + +class TestDailyLog: + def test_session_importable(self): + from rdagent.log.daily_log import session + assert callable(session) diff --git a/test/qlib/test_fx_config.py b/test/qlib/test_fx_config.py new file mode 100644 index 00000000..8ecda728 --- /dev/null +++ b/test/qlib/test_fx_config.py @@ -0,0 +1,64 @@ +"""Tests for fx_validator config (no langchain dependency).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +class TestFXConfig: + """Test config.py directly (bypasses __init__ which imports langchain).""" + + def test_config_is_dict(self): + # Import config module directly, not via package __init__ + import importlib.util + spec = importlib.util.spec_from_file_location( + "fx_validator_config", + PROJECT_ROOT / "rdagent/scenarios/qlib/fx_validator/config.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + assert isinstance(mod.FX_CONFIG, dict) + + def test_required_keys(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "fx_validator_config", + PROJECT_ROOT / "rdagent/scenarios/qlib/fx_validator/config.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + cfg = mod.FX_CONFIG + assert "instrument" in cfg + assert "max_debate_rounds" in cfg + assert "sessions" in cfg + assert "spread_bps" in cfg + + def test_sessions_have_four_zones(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "fx_validator_config", + PROJECT_ROOT / "rdagent/scenarios/qlib/fx_validator/config.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + sessions = mod.FX_CONFIG["sessions"] + for zone in ("asian", "london", "ny", "overlap"): + start, end = sessions[zone] + assert isinstance(start, str) + assert isinstance(end, str) + + def test_max_debate_rounds_positive(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "fx_validator_config", + PROJECT_ROOT / "rdagent/scenarios/qlib/fx_validator/config.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + assert mod.FX_CONFIG["max_debate_rounds"] > 0 diff --git a/test/qlib/test_predix_full_eval.py b/test/qlib/test_predix_full_eval.py new file mode 100644 index 00000000..b6dccaa2 --- /dev/null +++ b/test/qlib/test_predix_full_eval.py @@ -0,0 +1,79 @@ +"""Tests for scripts/predix_full_eval.py pure functions and dataclasses.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +class TestFactorInfo: + def test_construction(self): + from scripts.predix_full_eval import FactorInfo + fi = FactorInfo( + workspace_hash="abc123", + factor_name="test_factor", + factor_code="x=1", + ) + assert fi.workspace_hash == "abc123" + assert fi.factor_name == "test_factor" + assert fi.factor_code == "x=1" + + +class TestEvalResult: + def test_defaults(self): + from scripts.predix_full_eval import EvalResult + er = EvalResult(factor_name="f1", workspace_hash="h1") + assert er.status == "" + assert er.ic is None + assert er.error_message is None + assert er.non_null_count == 0 + + def test_failed_result(self): + from scripts.predix_full_eval import EvalResult + er = EvalResult( + factor_name="f1", workspace_hash="h1", + status="failed", error_message="timeout", + ) + assert er.status == "failed" + assert er.error_message == "timeout" + + def test_to_dict(self): + from scripts.predix_full_eval import EvalResult + er = EvalResult(factor_name="f1", workspace_hash="h1", status="success", ic=0.05) + d = er.to_dict() + assert d["factor_name"] == "f1" + assert d["ic"] == 0.05 + assert d["status"] == "success" + + +class TestExtractFactorDescription: + def test_docstring_extracted(self): + from scripts.predix_full_eval import _extract_factor_description + code = '"""This is a test factor.\nComputes momentum."""\nx=1' + desc = _extract_factor_description(code) + assert "test factor" in desc + + def test_comment_extraction(self): + from scripts.predix_full_eval import _extract_factor_description + code = "# Momentum factor\n# Uses 20-bar window\nx=1" + desc = _extract_factor_description(code) + assert "Momentum factor" in desc + assert "20-bar window" in desc + + def test_no_docstring_or_comments(self): + from scripts.predix_full_eval import _extract_factor_description + code = "x = 1\ny = 2\n" + desc = _extract_factor_description(code) + assert desc == "No description available" + + def test_shebang_skipped(self): + from scripts.predix_full_eval import _extract_factor_description + code = "#!/usr/bin/env python\n# Real comment\nx=1" + desc = _extract_factor_description(code) + assert "Real comment" in desc + assert "usr/bin" not in desc diff --git a/test/qlib/test_utils.py b/test/qlib/test_utils.py new file mode 100644 index 00000000..69619257 --- /dev/null +++ b/test/qlib/test_utils.py @@ -0,0 +1,113 @@ +"""Tests for utils/agent/apply_patch, core/prompts, utils/fmt.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).parent.parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + + +# ============================================================================= +# apply_patch data structures +# ============================================================================= + + +class TestApplyPatchDatastructures: + def test_action_type_enum(self): + from rdagent.utils.agent.apply_patch import ActionType + assert ActionType.ADD.value == "add" + assert ActionType.DELETE.value == "delete" + assert ActionType.UPDATE.value == "update" + + def test_file_change_dataclass(self): + from rdagent.utils.agent.apply_patch import FileChange, ActionType + fc = FileChange(type=ActionType.UPDATE, old_content="old", new_content="new") + assert fc.type == ActionType.UPDATE + assert fc.old_content == "old" + assert fc.new_content == "new" + assert fc.move_path is None + + def test_file_change_defaults(self): + from rdagent.utils.agent.apply_patch import FileChange, ActionType + fc = FileChange(type=ActionType.ADD) + assert fc.old_content is None + assert fc.new_content is None + + def test_commit_defaults(self): + from rdagent.utils.agent.apply_patch import Commit + c = Commit() + assert c.changes == {} + + def test_commit_with_changes(self): + from rdagent.utils.agent.apply_patch import Commit, FileChange, ActionType + c = Commit(changes={"test.py": FileChange(type=ActionType.UPDATE)}) + assert "test.py" in c.changes + + def test_diff_error_is_value_error(self): + from rdagent.utils.agent.apply_patch import DiffError + with pytest.raises(DiffError): + raise DiffError("test error") + + def test_chunk_dataclass(self): + from rdagent.utils.agent.apply_patch import Chunk + c = Chunk(orig_index=5, del_lines=["a"], ins_lines=["b", "c"]) + assert c.orig_index == 5 + assert c.del_lines == ["a"] + assert c.ins_lines == ["b", "c"] + + def test_patch_action_dataclass(self): + from rdagent.utils.agent.apply_patch import PatchAction, ActionType + pa = PatchAction(type=ActionType.ADD, new_file="test.py") + assert pa.type == ActionType.ADD + assert pa.new_file == "test.py" + + +# ============================================================================= +# Prompts (core/prompts.py) +# ============================================================================= + + +class TestPrompts: + def test_prompts_loads_yaml(self, tmp_path): + from rdagent.core.prompts import Prompts + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("key1: value1\nkey2: value2\n") + p = Prompts(file_path=yaml_file) + assert p["key1"] == "value1" + assert p["key2"] == "value2" + + def test_prompts_raises_on_empty(self, tmp_path): + from rdagent.core.prompts import Prompts + yaml_file = tmp_path / "empty.yaml" + yaml_file.write_text("") + with pytest.raises(ValueError, match="Failed to load"): + Prompts(file_path=yaml_file) + + def test_prompts_is_dict_subclass(self, tmp_path): + from rdagent.core.prompts import Prompts + yaml_file = tmp_path / "test.yaml" + yaml_file.write_text("k: v\n") + p = Prompts(file_path=yaml_file) + assert isinstance(p, dict) + assert len(p) == 1 + + +# ============================================================================= +# SingletonBaseClass (core/utils.py) +# ============================================================================= + + +class TestSingletonBaseClass: + def test_singleton_returns_same_instance(self): + from rdagent.core.utils import SingletonBaseClass + + class MySingleton(SingletonBaseClass): + pass + + a = MySingleton() + b = MySingleton() + assert a is b