|
| 1 | +"""Tests for openspace.skill_engine.evolution.formatting (Epic 5.6). |
| 2 | +
|
| 3 | +Verifies: |
| 4 | + 1. format_skill_dir_content — single-file, multi-file, empty |
| 5 | + 2. format_analysis_context — with analyses, empty, truncation |
| 6 | + 3. Backward compat: SkillEvolver delegates to formatting functions |
| 7 | + 4. Logger namespace |
| 8 | +""" |
| 9 | + |
| 10 | +from __future__ import annotations |
| 11 | + |
| 12 | +from pathlib import Path |
| 13 | +from unittest.mock import MagicMock, patch |
| 14 | + |
| 15 | +import pytest |
| 16 | + |
| 17 | +from openspace.skill_engine.evolution.formatting import ( |
| 18 | + format_analysis_context, |
| 19 | + format_skill_dir_content, |
| 20 | +) |
| 21 | + |
| 22 | + |
| 23 | +# --------------------------------------------------------------------------- |
| 24 | +# format_skill_dir_content |
| 25 | +# --------------------------------------------------------------------------- |
| 26 | + |
| 27 | +class TestFormatSkillDirContent: |
| 28 | + def test_empty_dir(self): |
| 29 | + """Empty directory → empty string.""" |
| 30 | + with patch("openspace.skill_engine.evolution.formatting.collect_skill_snapshot", return_value={}): |
| 31 | + result = format_skill_dir_content(Path("/fake")) |
| 32 | + assert result == "" |
| 33 | + |
| 34 | + def test_single_file_skill(self): |
| 35 | + """Single SKILL.md → return content directly (no formatting).""" |
| 36 | + with patch( |
| 37 | + "openspace.skill_engine.evolution.formatting.collect_skill_snapshot", |
| 38 | + return_value={"SKILL.md": "---\nname: test\n---\ncontent"}, |
| 39 | + ): |
| 40 | + result = format_skill_dir_content(Path("/fake")) |
| 41 | + assert result == "---\nname: test\n---\ncontent" |
| 42 | + |
| 43 | + def test_multi_file_skill(self): |
| 44 | + """Multiple files → formatted listing with SKILL.md first.""" |
| 45 | + files = { |
| 46 | + "SKILL.md": "skill content", |
| 47 | + "helper.py": "def foo(): pass", |
| 48 | + "README.md": "readme text", |
| 49 | + } |
| 50 | + with patch( |
| 51 | + "openspace.skill_engine.evolution.formatting.collect_skill_snapshot", |
| 52 | + return_value=files, |
| 53 | + ): |
| 54 | + result = format_skill_dir_content(Path("/fake")) |
| 55 | + |
| 56 | + # SKILL.md appears first |
| 57 | + assert result.index("### File: SKILL.md") < result.index("### File: README.md") |
| 58 | + assert result.index("### File: SKILL.md") < result.index("### File: helper.py") |
| 59 | + # All files present |
| 60 | + assert "skill content" in result |
| 61 | + assert "def foo(): pass" in result |
| 62 | + assert "readme text" in result |
| 63 | + |
| 64 | + def test_multi_file_without_skill_md(self): |
| 65 | + """Multi-file with no SKILL.md → still formats all files.""" |
| 66 | + files = {"helper.py": "code", "config.yaml": "key: val"} |
| 67 | + with patch( |
| 68 | + "openspace.skill_engine.evolution.formatting.collect_skill_snapshot", |
| 69 | + return_value=files, |
| 70 | + ): |
| 71 | + result = format_skill_dir_content(Path("/fake")) |
| 72 | + assert "### File: config.yaml" in result |
| 73 | + assert "### File: helper.py" in result |
| 74 | + |
| 75 | + |
| 76 | +# --------------------------------------------------------------------------- |
| 77 | +# format_analysis_context |
| 78 | +# --------------------------------------------------------------------------- |
| 79 | + |
| 80 | +class _FakeSkillJudgment: |
| 81 | + def __init__(self, skill_id, applied=True, note=""): |
| 82 | + self.skill_id = skill_id |
| 83 | + self.skill_applied = applied |
| 84 | + self.note = note |
| 85 | + |
| 86 | + |
| 87 | +class _FakeAnalysis: |
| 88 | + def __init__(self, task_id, completed=True, note="", judgments=None, tool_issues=None): |
| 89 | + self.task_id = task_id |
| 90 | + self.task_completed = completed |
| 91 | + self.execution_note = note |
| 92 | + self.skill_judgments = judgments or [] |
| 93 | + self.tool_issues = tool_issues or [] |
| 94 | + |
| 95 | + |
| 96 | +class TestFormatAnalysisContext: |
| 97 | + def test_empty_analyses(self): |
| 98 | + """No analyses → fallback message.""" |
| 99 | + result = format_analysis_context([]) |
| 100 | + assert "no execution history" in result.lower() |
| 101 | + |
| 102 | + def test_single_analysis(self): |
| 103 | + """Single analysis → formatted block.""" |
| 104 | + analysis = _FakeAnalysis( |
| 105 | + task_id="task-1", |
| 106 | + completed=True, |
| 107 | + note="Skill worked well", |
| 108 | + judgments=[_FakeSkillJudgment("skill-a", applied=True, note="Good result")], |
| 109 | + ) |
| 110 | + result = format_analysis_context([analysis]) |
| 111 | + assert "task-1" in result |
| 112 | + assert "completed" in result |
| 113 | + assert "skill-a" in result |
| 114 | + assert "applied" in result |
| 115 | + assert "Good result" in result |
| 116 | + |
| 117 | + def test_failed_task(self): |
| 118 | + """Failed task shows 'failed' label.""" |
| 119 | + analysis = _FakeAnalysis(task_id="task-2", completed=False) |
| 120 | + result = format_analysis_context([analysis]) |
| 121 | + assert "failed" in result |
| 122 | + |
| 123 | + def test_tool_issues_included(self): |
| 124 | + """Tool issues appear in output.""" |
| 125 | + analysis = _FakeAnalysis( |
| 126 | + task_id="task-3", |
| 127 | + tool_issues=["Tool X timed out", "Tool Y returned error"], |
| 128 | + ) |
| 129 | + result = format_analysis_context([analysis]) |
| 130 | + assert "Tool X timed out" in result |
| 131 | + assert "Tool Y returned error" in result |
| 132 | + |
| 133 | + def test_note_truncation(self): |
| 134 | + """Execution notes are truncated to 500 chars.""" |
| 135 | + long_note = "x" * 1000 |
| 136 | + analysis = _FakeAnalysis(task_id="task-4", note=long_note) |
| 137 | + result = format_analysis_context([analysis]) |
| 138 | + # The note should be truncated — full 1000 chars should NOT appear |
| 139 | + assert "x" * 501 not in result |
| 140 | + |
| 141 | + def test_not_applied_skill(self): |
| 142 | + """Skill not applied shows 'NOT applied'.""" |
| 143 | + analysis = _FakeAnalysis( |
| 144 | + task_id="task-5", |
| 145 | + judgments=[_FakeSkillJudgment("skill-b", applied=False, note="Didn't match")], |
| 146 | + ) |
| 147 | + result = format_analysis_context([analysis]) |
| 148 | + assert "NOT applied" in result |
| 149 | + |
| 150 | + |
| 151 | +# --------------------------------------------------------------------------- |
| 152 | +# Backward compat |
| 153 | +# --------------------------------------------------------------------------- |
| 154 | + |
| 155 | +class TestDelegationSeam: |
| 156 | + def test_format_skill_dir_is_staticmethod(self): |
| 157 | + from openspace.skill_engine.evolver import SkillEvolver |
| 158 | + assert isinstance( |
| 159 | + SkillEvolver.__dict__["_format_skill_dir_content"], |
| 160 | + staticmethod, |
| 161 | + ) |
| 162 | + |
| 163 | + def test_format_analysis_is_staticmethod(self): |
| 164 | + from openspace.skill_engine.evolver import SkillEvolver |
| 165 | + assert isinstance( |
| 166 | + SkillEvolver.__dict__["_format_analysis_context"], |
| 167 | + staticmethod, |
| 168 | + ) |
| 169 | + |
| 170 | + def test_format_skill_dir_callable(self): |
| 171 | + from openspace.skill_engine.evolver import SkillEvolver |
| 172 | + with patch( |
| 173 | + "openspace.skill_engine.evolution.formatting.collect_skill_snapshot", |
| 174 | + return_value={"SKILL.md": "test"}, |
| 175 | + ): |
| 176 | + result = SkillEvolver._format_skill_dir_content(Path("/fake")) |
| 177 | + assert result == "test" |
| 178 | + |
| 179 | + |
| 180 | +# --------------------------------------------------------------------------- |
| 181 | +# Constants / logger |
| 182 | +# --------------------------------------------------------------------------- |
| 183 | + |
| 184 | +class TestMeta: |
| 185 | + def test_logger_uses_evolver_namespace(self): |
| 186 | + from openspace.skill_engine.evolution.formatting import logger |
| 187 | + assert "evolver" in logger.name |
| 188 | + |
| 189 | + def test_module_size(self): |
| 190 | + """formatting.py should stay under 120 lines.""" |
| 191 | + import openspace.skill_engine.evolution.formatting as mod |
| 192 | + src = Path(mod.__file__) |
| 193 | + lines = src.read_text(encoding="utf-8").splitlines() |
| 194 | + assert len(lines) <= 120, f"formatting.py has {len(lines)} lines (limit 120)" |
0 commit comments