devforgeai 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +120 -0
- package/bin/devforgeai.js +0 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/hooks/phase-completion-gate.sh +0 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for config_manager.py module.
|
|
3
|
+
|
|
4
|
+
Validates configuration loading, YAML parsing, default merging,
|
|
5
|
+
validation, hot-reload integration, and singleton pattern.
|
|
6
|
+
Target: 95% coverage of 161 statements.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import yaml
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
15
|
+
from devforgeai_cli.feedback.config_manager import (
|
|
16
|
+
ConfigurationManager,
|
|
17
|
+
get_config_manager,
|
|
18
|
+
reset_config_manager
|
|
19
|
+
)
|
|
20
|
+
from devforgeai_cli.feedback.config_models import FeedbackConfiguration, ConversationSettings, SkipTrackingSettings, TemplateSettings
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def temp_config_file(tmp_path):
|
|
25
|
+
"""Provide a temporary config file path."""
|
|
26
|
+
return tmp_path / "feedback.yaml"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def temp_logs_dir(tmp_path):
|
|
31
|
+
"""Provide a temporary logs directory."""
|
|
32
|
+
return tmp_path / "logs"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def manager(temp_config_file, temp_logs_dir):
|
|
37
|
+
"""Provide a fresh ConfigurationManager instance."""
|
|
38
|
+
return ConfigurationManager(
|
|
39
|
+
config_file_path=temp_config_file,
|
|
40
|
+
logs_dir=temp_logs_dir,
|
|
41
|
+
enable_hot_reload=False # Disable hot-reload for most tests
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def valid_yaml_content():
|
|
47
|
+
"""Provide valid YAML configuration content."""
|
|
48
|
+
return """
|
|
49
|
+
enabled: true
|
|
50
|
+
trigger_mode: always
|
|
51
|
+
operations:
|
|
52
|
+
- qa
|
|
53
|
+
- dev
|
|
54
|
+
conversation_settings:
|
|
55
|
+
max_questions: 10
|
|
56
|
+
allow_skip: false
|
|
57
|
+
skip_tracking:
|
|
58
|
+
enabled: true
|
|
59
|
+
max_consecutive_skips: 5
|
|
60
|
+
reset_on_positive: true
|
|
61
|
+
templates:
|
|
62
|
+
format: free-text
|
|
63
|
+
tone: detailed
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestConfigurationManagerInitialization:
|
|
68
|
+
"""Tests for ConfigurationManager initialization."""
|
|
69
|
+
|
|
70
|
+
def test_init_with_custom_paths(self, temp_config_file, temp_logs_dir):
|
|
71
|
+
"""ConfigurationManager accepts custom paths."""
|
|
72
|
+
manager = ConfigurationManager(
|
|
73
|
+
config_file_path=temp_config_file,
|
|
74
|
+
logs_dir=temp_logs_dir,
|
|
75
|
+
enable_hot_reload=False
|
|
76
|
+
)
|
|
77
|
+
assert manager.config_file_path == temp_config_file
|
|
78
|
+
assert manager.logs_dir == temp_logs_dir
|
|
79
|
+
|
|
80
|
+
def test_init_with_default_paths(self):
|
|
81
|
+
"""ConfigurationManager uses defaults when paths not provided."""
|
|
82
|
+
manager = ConfigurationManager(enable_hot_reload=False)
|
|
83
|
+
assert manager.config_file_path == Path("devforgeai/config/feedback.yaml")
|
|
84
|
+
assert manager.logs_dir == Path("devforgeai/logs")
|
|
85
|
+
|
|
86
|
+
def test_init_creates_logs_directory(self, temp_config_file, temp_logs_dir):
|
|
87
|
+
"""Initialization creates logs directory if it doesn't exist."""
|
|
88
|
+
manager = ConfigurationManager(
|
|
89
|
+
config_file_path=temp_config_file,
|
|
90
|
+
logs_dir=temp_logs_dir,
|
|
91
|
+
enable_hot_reload=False
|
|
92
|
+
)
|
|
93
|
+
assert temp_logs_dir.exists()
|
|
94
|
+
|
|
95
|
+
def test_init_loads_configuration(self, manager):
|
|
96
|
+
"""Initialization loads configuration (defaults if file missing)."""
|
|
97
|
+
assert manager._current_config is not None
|
|
98
|
+
assert isinstance(manager._current_config, FeedbackConfiguration)
|
|
99
|
+
|
|
100
|
+
def test_init_creates_skip_tracker(self, manager):
|
|
101
|
+
"""Initialization creates skip tracker instance."""
|
|
102
|
+
assert manager._skip_tracker is not None
|
|
103
|
+
|
|
104
|
+
def test_init_hot_reload_disabled(self, manager):
|
|
105
|
+
"""Hot-reload is disabled when enable_hot_reload=False."""
|
|
106
|
+
assert manager._hot_reload_manager is None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TestYAMLLoading:
|
|
110
|
+
"""Tests for YAML file loading and parsing."""
|
|
111
|
+
|
|
112
|
+
def test_load_yaml_file_not_exists(self, manager):
|
|
113
|
+
"""_load_yaml_file returns None when file doesn't exist."""
|
|
114
|
+
result = manager._load_yaml_file()
|
|
115
|
+
assert result is None
|
|
116
|
+
|
|
117
|
+
def test_load_yaml_file_valid(self, temp_config_file, manager, valid_yaml_content):
|
|
118
|
+
"""_load_yaml_file parses valid YAML correctly."""
|
|
119
|
+
temp_config_file.write_text(valid_yaml_content)
|
|
120
|
+
result = manager._load_yaml_file()
|
|
121
|
+
|
|
122
|
+
assert isinstance(result, dict)
|
|
123
|
+
assert result["enabled"] is True
|
|
124
|
+
assert result["trigger_mode"] == "always"
|
|
125
|
+
|
|
126
|
+
def test_load_yaml_file_empty(self, temp_config_file, manager):
|
|
127
|
+
"""_load_yaml_file returns empty dict for empty file."""
|
|
128
|
+
temp_config_file.write_text("")
|
|
129
|
+
result = manager._load_yaml_file()
|
|
130
|
+
assert result == {}
|
|
131
|
+
|
|
132
|
+
def test_load_yaml_file_invalid_syntax(self, temp_config_file, manager):
|
|
133
|
+
"""_load_yaml_file raises YAMLError for invalid syntax."""
|
|
134
|
+
temp_config_file.write_text("invalid: yaml: syntax: [")
|
|
135
|
+
with pytest.raises(yaml.YAMLError):
|
|
136
|
+
manager._load_yaml_file()
|
|
137
|
+
|
|
138
|
+
def test_load_yaml_file_not_dict(self, temp_config_file, manager):
|
|
139
|
+
"""_load_yaml_file returns {} when YAML is not a dictionary."""
|
|
140
|
+
temp_config_file.write_text("- list\n- items")
|
|
141
|
+
result = manager._load_yaml_file()
|
|
142
|
+
assert result == {}
|
|
143
|
+
|
|
144
|
+
def test_load_yaml_file_permission_error(self, temp_config_file, manager):
|
|
145
|
+
"""_load_yaml_file raises IOError for permission errors."""
|
|
146
|
+
temp_config_file.write_text("enabled: true")
|
|
147
|
+
temp_config_file.chmod(0o000) # Remove all permissions
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
with pytest.raises((IOError, OSError)):
|
|
151
|
+
manager._load_yaml_file()
|
|
152
|
+
finally:
|
|
153
|
+
temp_config_file.chmod(0o644) # Restore permissions
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestConfigurationMerging:
|
|
157
|
+
"""Tests for configuration merging with defaults."""
|
|
158
|
+
|
|
159
|
+
def test_merge_with_defaults_none_config(self, manager):
|
|
160
|
+
"""_merge_with_defaults returns full defaults when config is None."""
|
|
161
|
+
result = manager._merge_with_defaults(None)
|
|
162
|
+
assert result["enabled"] is True
|
|
163
|
+
assert result["trigger_mode"] == "failures-only"
|
|
164
|
+
assert result["conversation_settings"]["max_questions"] == 5
|
|
165
|
+
|
|
166
|
+
def test_merge_with_defaults_partial_config(self, manager):
|
|
167
|
+
"""_merge_with_defaults merges partial config with defaults."""
|
|
168
|
+
partial = {"enabled": False}
|
|
169
|
+
result = manager._merge_with_defaults(partial)
|
|
170
|
+
|
|
171
|
+
assert result["enabled"] is False # Override
|
|
172
|
+
assert result["trigger_mode"] == "failures-only" # Default
|
|
173
|
+
assert result["conversation_settings"]["max_questions"] == 5 # Default
|
|
174
|
+
|
|
175
|
+
def test_merge_with_defaults_override_trigger_mode(self, manager):
|
|
176
|
+
"""_merge_with_defaults allows overriding trigger_mode."""
|
|
177
|
+
config = {"trigger_mode": "always"}
|
|
178
|
+
result = manager._merge_with_defaults(config)
|
|
179
|
+
assert result["trigger_mode"] == "always"
|
|
180
|
+
|
|
181
|
+
def test_merge_with_defaults_nested_merge_conversation(self, manager):
|
|
182
|
+
"""_merge_with_defaults deeply merges conversation_settings."""
|
|
183
|
+
config = {
|
|
184
|
+
"conversation_settings": {
|
|
185
|
+
"max_questions": 10
|
|
186
|
+
# allow_skip missing - should get default
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
result = manager._merge_with_defaults(config)
|
|
190
|
+
|
|
191
|
+
assert result["conversation_settings"]["max_questions"] == 10 # Override
|
|
192
|
+
assert result["conversation_settings"]["allow_skip"] is True # Default
|
|
193
|
+
|
|
194
|
+
def test_merge_with_defaults_nested_merge_skip_tracking(self, manager):
|
|
195
|
+
"""_merge_with_defaults deeply merges skip_tracking."""
|
|
196
|
+
config = {
|
|
197
|
+
"skip_tracking": {
|
|
198
|
+
"enabled": False
|
|
199
|
+
# Other fields missing - should get defaults
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
result = manager._merge_with_defaults(config)
|
|
203
|
+
|
|
204
|
+
assert result["skip_tracking"]["enabled"] is False # Override
|
|
205
|
+
assert result["skip_tracking"]["max_consecutive_skips"] == 3 # Default
|
|
206
|
+
assert result["skip_tracking"]["reset_on_positive"] is True # Default
|
|
207
|
+
|
|
208
|
+
def test_merge_with_defaults_nested_merge_templates(self, manager):
|
|
209
|
+
"""_merge_with_defaults deeply merges templates."""
|
|
210
|
+
config = {
|
|
211
|
+
"templates": {
|
|
212
|
+
"format": "free-text"
|
|
213
|
+
# tone missing - should get default
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
result = manager._merge_with_defaults(config)
|
|
217
|
+
|
|
218
|
+
assert result["templates"]["format"] == "free-text" # Override
|
|
219
|
+
assert result["templates"]["tone"] == "brief" # Default
|
|
220
|
+
|
|
221
|
+
def test_merge_with_defaults_full_custom_config(self, manager):
|
|
222
|
+
"""_merge_with_defaults handles fully custom configuration."""
|
|
223
|
+
config = {
|
|
224
|
+
"enabled": False,
|
|
225
|
+
"trigger_mode": "never",
|
|
226
|
+
"operations": None,
|
|
227
|
+
"conversation_settings": {
|
|
228
|
+
"max_questions": 0,
|
|
229
|
+
"allow_skip": False
|
|
230
|
+
},
|
|
231
|
+
"skip_tracking": {
|
|
232
|
+
"enabled": False,
|
|
233
|
+
"max_consecutive_skips": 10,
|
|
234
|
+
"reset_on_positive": False
|
|
235
|
+
},
|
|
236
|
+
"templates": {
|
|
237
|
+
"format": "free-text",
|
|
238
|
+
"tone": "detailed"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
result = manager._merge_with_defaults(config)
|
|
242
|
+
|
|
243
|
+
assert result["enabled"] is False
|
|
244
|
+
assert result["trigger_mode"] == "never"
|
|
245
|
+
assert result["conversation_settings"]["max_questions"] == 0
|
|
246
|
+
assert result["templates"]["format"] == "free-text"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestConfigurationValidation:
|
|
250
|
+
"""Tests for configuration validation."""
|
|
251
|
+
|
|
252
|
+
def test_dict_to_configuration_valid(self, manager):
|
|
253
|
+
"""_dict_to_configuration creates valid FeedbackConfiguration."""
|
|
254
|
+
config_dict = {
|
|
255
|
+
"enabled": True,
|
|
256
|
+
"trigger_mode": "always",
|
|
257
|
+
"operations": ["qa"],
|
|
258
|
+
"conversation_settings": {"max_questions": 10, "allow_skip": True},
|
|
259
|
+
"skip_tracking": {"enabled": True, "max_consecutive_skips": 5, "reset_on_positive": True},
|
|
260
|
+
"templates": {"format": "structured", "tone": "brief"}
|
|
261
|
+
}
|
|
262
|
+
config = manager._dict_to_configuration(config_dict)
|
|
263
|
+
|
|
264
|
+
assert isinstance(config, FeedbackConfiguration)
|
|
265
|
+
assert config.enabled is True
|
|
266
|
+
assert config.trigger_mode == "always"
|
|
267
|
+
|
|
268
|
+
def test_dict_to_configuration_invalid_trigger_mode(self, manager):
|
|
269
|
+
"""_dict_to_configuration raises ValueError for invalid trigger_mode."""
|
|
270
|
+
config_dict = {"trigger_mode": "invalid-mode"}
|
|
271
|
+
with pytest.raises(ValueError) as exc_info:
|
|
272
|
+
manager._dict_to_configuration(config_dict)
|
|
273
|
+
assert "Invalid trigger_mode" in str(exc_info.value)
|
|
274
|
+
|
|
275
|
+
def test_dict_to_configuration_missing_operations_for_specific(self, manager):
|
|
276
|
+
"""_dict_to_configuration raises ValueError when specific-operations missing operations."""
|
|
277
|
+
config_dict = {
|
|
278
|
+
"trigger_mode": "specific-operations",
|
|
279
|
+
"operations": None
|
|
280
|
+
}
|
|
281
|
+
with pytest.raises(ValueError) as exc_info:
|
|
282
|
+
manager._dict_to_configuration(config_dict)
|
|
283
|
+
assert "operations list must be provided" in str(exc_info.value)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
class TestConfigurationLoading:
|
|
287
|
+
"""Tests for load_configuration method (AC-1, AC-8)."""
|
|
288
|
+
|
|
289
|
+
def test_load_configuration_file_not_exists(self, manager):
|
|
290
|
+
"""load_configuration returns defaults when file doesn't exist (AC-8)."""
|
|
291
|
+
config = manager.load_configuration()
|
|
292
|
+
|
|
293
|
+
assert isinstance(config, FeedbackConfiguration)
|
|
294
|
+
assert config.enabled is True
|
|
295
|
+
assert config.trigger_mode == "failures-only"
|
|
296
|
+
assert config.conversation_settings.max_questions == 5
|
|
297
|
+
|
|
298
|
+
def test_load_configuration_valid_file(self, temp_config_file, temp_logs_dir, valid_yaml_content):
|
|
299
|
+
"""load_configuration parses valid YAML file (AC-1)."""
|
|
300
|
+
temp_config_file.write_text(valid_yaml_content)
|
|
301
|
+
manager = ConfigurationManager(
|
|
302
|
+
config_file_path=temp_config_file,
|
|
303
|
+
logs_dir=temp_logs_dir,
|
|
304
|
+
enable_hot_reload=False
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
config = manager.load_configuration()
|
|
308
|
+
assert config.enabled is True
|
|
309
|
+
assert config.trigger_mode == "always"
|
|
310
|
+
assert config.conversation_settings.max_questions == 10
|
|
311
|
+
|
|
312
|
+
def test_load_configuration_partial_file(self, temp_config_file, temp_logs_dir):
|
|
313
|
+
"""load_configuration merges partial config with defaults (AC-8)."""
|
|
314
|
+
temp_config_file.write_text("enabled: false\n")
|
|
315
|
+
manager = ConfigurationManager(
|
|
316
|
+
config_file_path=temp_config_file,
|
|
317
|
+
logs_dir=temp_logs_dir,
|
|
318
|
+
enable_hot_reload=False
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
config = manager.load_configuration()
|
|
322
|
+
assert config.enabled is False # From file
|
|
323
|
+
assert config.trigger_mode == "failures-only" # Default
|
|
324
|
+
assert config.conversation_settings.max_questions == 5 # Default
|
|
325
|
+
|
|
326
|
+
def test_load_configuration_invalid_yaml_returns_defaults(self, temp_config_file, temp_logs_dir):
|
|
327
|
+
"""load_configuration returns defaults when YAML invalid (error handling)."""
|
|
328
|
+
temp_config_file.write_text("invalid: [yaml: syntax")
|
|
329
|
+
manager = ConfigurationManager(
|
|
330
|
+
config_file_path=temp_config_file,
|
|
331
|
+
logs_dir=temp_logs_dir,
|
|
332
|
+
enable_hot_reload=False
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Should catch exception and return defaults
|
|
336
|
+
config = manager.load_configuration()
|
|
337
|
+
assert isinstance(config, FeedbackConfiguration)
|
|
338
|
+
assert config.enabled is True # Default
|
|
339
|
+
assert config.trigger_mode == "failures-only" # Default
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class TestGetterMethods:
|
|
343
|
+
"""Tests for configuration getter methods."""
|
|
344
|
+
|
|
345
|
+
def test_get_configuration(self, manager):
|
|
346
|
+
"""get_configuration returns current configuration."""
|
|
347
|
+
config = manager.get_configuration()
|
|
348
|
+
assert isinstance(config, FeedbackConfiguration)
|
|
349
|
+
|
|
350
|
+
def test_is_enabled_true(self, temp_config_file, temp_logs_dir):
|
|
351
|
+
"""is_enabled returns True when enabled in config (AC-2)."""
|
|
352
|
+
temp_config_file.write_text("enabled: true\n")
|
|
353
|
+
manager = ConfigurationManager(
|
|
354
|
+
config_file_path=temp_config_file,
|
|
355
|
+
logs_dir=temp_logs_dir,
|
|
356
|
+
enable_hot_reload=False
|
|
357
|
+
)
|
|
358
|
+
assert manager.is_enabled() is True
|
|
359
|
+
|
|
360
|
+
def test_is_enabled_false(self, temp_config_file, temp_logs_dir):
|
|
361
|
+
"""is_enabled returns False when disabled in config (AC-2)."""
|
|
362
|
+
temp_config_file.write_text("enabled: false\n")
|
|
363
|
+
manager = ConfigurationManager(
|
|
364
|
+
config_file_path=temp_config_file,
|
|
365
|
+
logs_dir=temp_logs_dir,
|
|
366
|
+
enable_hot_reload=False
|
|
367
|
+
)
|
|
368
|
+
assert manager.is_enabled() is False
|
|
369
|
+
|
|
370
|
+
def test_get_trigger_mode(self, temp_config_file, temp_logs_dir):
|
|
371
|
+
"""get_trigger_mode returns configured trigger mode (AC-3)."""
|
|
372
|
+
temp_config_file.write_text("trigger_mode: always\n")
|
|
373
|
+
manager = ConfigurationManager(
|
|
374
|
+
config_file_path=temp_config_file,
|
|
375
|
+
logs_dir=temp_logs_dir,
|
|
376
|
+
enable_hot_reload=False
|
|
377
|
+
)
|
|
378
|
+
assert manager.get_trigger_mode() == "always"
|
|
379
|
+
|
|
380
|
+
def test_get_operations(self, temp_config_file, temp_logs_dir):
|
|
381
|
+
"""get_operations returns configured operations list."""
|
|
382
|
+
temp_config_file.write_text("operations:\n - qa\n - dev\n")
|
|
383
|
+
manager = ConfigurationManager(
|
|
384
|
+
config_file_path=temp_config_file,
|
|
385
|
+
logs_dir=temp_logs_dir,
|
|
386
|
+
enable_hot_reload=False
|
|
387
|
+
)
|
|
388
|
+
assert manager.get_operations() == ["qa", "dev"]
|
|
389
|
+
|
|
390
|
+
def test_get_conversation_settings(self, manager):
|
|
391
|
+
"""get_conversation_settings returns ConversationSettings object (AC-4)."""
|
|
392
|
+
settings = manager.get_conversation_settings()
|
|
393
|
+
assert isinstance(settings, ConversationSettings)
|
|
394
|
+
assert settings.max_questions == 5
|
|
395
|
+
|
|
396
|
+
def test_get_skip_tracking_settings(self, manager):
|
|
397
|
+
"""get_skip_tracking_settings returns SkipTrackingSettings object (AC-5)."""
|
|
398
|
+
settings = manager.get_skip_tracking_settings()
|
|
399
|
+
assert isinstance(settings, SkipTrackingSettings)
|
|
400
|
+
assert settings.max_consecutive_skips == 3
|
|
401
|
+
|
|
402
|
+
def test_get_template_settings(self, manager):
|
|
403
|
+
"""get_template_settings returns TemplateSettings object (AC-6)."""
|
|
404
|
+
settings = manager.get_template_settings()
|
|
405
|
+
assert isinstance(settings, TemplateSettings)
|
|
406
|
+
assert settings.format == "structured"
|
|
407
|
+
|
|
408
|
+
def test_get_skip_tracker(self, manager):
|
|
409
|
+
"""get_skip_tracker returns SkipTracker instance."""
|
|
410
|
+
tracker = manager.get_skip_tracker()
|
|
411
|
+
assert tracker is not None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class TestHotReloadIntegration:
|
|
415
|
+
"""Tests for hot-reload manager integration (AC-9)."""
|
|
416
|
+
|
|
417
|
+
def test_init_hot_reload_enabled(self, temp_config_file, temp_logs_dir):
|
|
418
|
+
"""Hot-reload manager is created when enable_hot_reload=True."""
|
|
419
|
+
manager = ConfigurationManager(
|
|
420
|
+
config_file_path=temp_config_file,
|
|
421
|
+
logs_dir=temp_logs_dir,
|
|
422
|
+
enable_hot_reload=True
|
|
423
|
+
)
|
|
424
|
+
assert manager._hot_reload_manager is not None
|
|
425
|
+
# Cleanup
|
|
426
|
+
manager.shutdown()
|
|
427
|
+
|
|
428
|
+
def test_is_hot_reload_enabled_true(self, temp_config_file, temp_logs_dir):
|
|
429
|
+
"""is_hot_reload_enabled returns True when hot-reload active."""
|
|
430
|
+
manager = ConfigurationManager(
|
|
431
|
+
config_file_path=temp_config_file,
|
|
432
|
+
logs_dir=temp_logs_dir,
|
|
433
|
+
enable_hot_reload=True
|
|
434
|
+
)
|
|
435
|
+
# Give watcher time to start
|
|
436
|
+
time.sleep(0.1)
|
|
437
|
+
assert manager.is_hot_reload_enabled() is True
|
|
438
|
+
manager.shutdown()
|
|
439
|
+
|
|
440
|
+
def test_is_hot_reload_enabled_false(self, manager):
|
|
441
|
+
"""is_hot_reload_enabled returns False when not enabled."""
|
|
442
|
+
assert manager.is_hot_reload_enabled() is False
|
|
443
|
+
|
|
444
|
+
def test_start_hot_reload(self, temp_config_file, temp_logs_dir):
|
|
445
|
+
"""start_hot_reload starts the watcher."""
|
|
446
|
+
manager = ConfigurationManager(
|
|
447
|
+
config_file_path=temp_config_file,
|
|
448
|
+
logs_dir=temp_logs_dir,
|
|
449
|
+
enable_hot_reload=True
|
|
450
|
+
)
|
|
451
|
+
manager.stop_hot_reload()
|
|
452
|
+
assert manager.is_hot_reload_enabled() is False
|
|
453
|
+
|
|
454
|
+
manager.start_hot_reload()
|
|
455
|
+
time.sleep(0.1)
|
|
456
|
+
assert manager.is_hot_reload_enabled() is True
|
|
457
|
+
manager.shutdown()
|
|
458
|
+
|
|
459
|
+
def test_stop_hot_reload(self, temp_config_file, temp_logs_dir):
|
|
460
|
+
"""stop_hot_reload stops the watcher."""
|
|
461
|
+
manager = ConfigurationManager(
|
|
462
|
+
config_file_path=temp_config_file,
|
|
463
|
+
logs_dir=temp_logs_dir,
|
|
464
|
+
enable_hot_reload=True
|
|
465
|
+
)
|
|
466
|
+
time.sleep(0.1)
|
|
467
|
+
manager.stop_hot_reload()
|
|
468
|
+
assert manager.is_hot_reload_enabled() is False
|
|
469
|
+
|
|
470
|
+
def test_shutdown(self, temp_config_file, temp_logs_dir):
|
|
471
|
+
"""shutdown stops hot-reload."""
|
|
472
|
+
manager = ConfigurationManager(
|
|
473
|
+
config_file_path=temp_config_file,
|
|
474
|
+
logs_dir=temp_logs_dir,
|
|
475
|
+
enable_hot_reload=True
|
|
476
|
+
)
|
|
477
|
+
time.sleep(0.1)
|
|
478
|
+
manager.shutdown()
|
|
479
|
+
assert manager.is_hot_reload_enabled() is False
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
class TestErrorLogging:
|
|
483
|
+
"""Tests for error logging functionality."""
|
|
484
|
+
|
|
485
|
+
def test_log_error_creates_log_file(self, manager, temp_logs_dir):
|
|
486
|
+
"""_log_error creates error log file."""
|
|
487
|
+
manager._log_error("Test error message")
|
|
488
|
+
error_log = temp_logs_dir / "config-errors.log"
|
|
489
|
+
assert error_log.exists()
|
|
490
|
+
|
|
491
|
+
def test_log_error_contains_timestamp(self, manager, temp_logs_dir):
|
|
492
|
+
"""Error log entries contain timestamp."""
|
|
493
|
+
manager._log_error("Test error")
|
|
494
|
+
error_log = temp_logs_dir / "config-errors.log"
|
|
495
|
+
|
|
496
|
+
with open(error_log, 'r') as f:
|
|
497
|
+
content = f.read()
|
|
498
|
+
assert "T" in content # ISO timestamp marker
|
|
499
|
+
assert "Test error" in content
|
|
500
|
+
|
|
501
|
+
def test_log_error_appends_to_file(self, manager, temp_logs_dir):
|
|
502
|
+
"""_log_error appends multiple errors to log."""
|
|
503
|
+
manager._log_error("Error 1")
|
|
504
|
+
manager._log_error("Error 2")
|
|
505
|
+
|
|
506
|
+
error_log = temp_logs_dir / "config-errors.log"
|
|
507
|
+
with open(error_log, 'r') as f:
|
|
508
|
+
content = f.read()
|
|
509
|
+
assert content.count("Error 1") == 1
|
|
510
|
+
assert content.count("Error 2") == 1
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
class TestSingletonPattern:
|
|
514
|
+
"""Tests for global singleton pattern."""
|
|
515
|
+
|
|
516
|
+
def test_get_config_manager_creates_instance(self):
|
|
517
|
+
"""get_config_manager creates ConfigurationManager instance."""
|
|
518
|
+
reset_config_manager()
|
|
519
|
+
manager = get_config_manager()
|
|
520
|
+
assert isinstance(manager, ConfigurationManager)
|
|
521
|
+
manager.shutdown()
|
|
522
|
+
reset_config_manager()
|
|
523
|
+
|
|
524
|
+
def test_get_config_manager_returns_same_instance(self):
|
|
525
|
+
"""get_config_manager returns same instance on multiple calls."""
|
|
526
|
+
reset_config_manager()
|
|
527
|
+
manager1 = get_config_manager()
|
|
528
|
+
manager2 = get_config_manager()
|
|
529
|
+
assert manager1 is manager2
|
|
530
|
+
manager1.shutdown()
|
|
531
|
+
reset_config_manager()
|
|
532
|
+
|
|
533
|
+
def test_reset_config_manager_clears_global(self):
|
|
534
|
+
"""reset_config_manager clears global instance."""
|
|
535
|
+
manager1 = get_config_manager()
|
|
536
|
+
reset_config_manager()
|
|
537
|
+
manager2 = get_config_manager()
|
|
538
|
+
assert manager1 is not manager2
|
|
539
|
+
manager2.shutdown()
|
|
540
|
+
reset_config_manager()
|
|
541
|
+
|
|
542
|
+
def test_get_config_manager_thread_safe(self):
|
|
543
|
+
"""get_config_manager is thread-safe (no duplicate instances)."""
|
|
544
|
+
reset_config_manager()
|
|
545
|
+
managers = []
|
|
546
|
+
|
|
547
|
+
def get_manager():
|
|
548
|
+
managers.append(get_config_manager())
|
|
549
|
+
|
|
550
|
+
threads = [threading.Thread(target=get_manager) for _ in range(10)]
|
|
551
|
+
for t in threads:
|
|
552
|
+
t.start()
|
|
553
|
+
for t in threads:
|
|
554
|
+
t.join()
|
|
555
|
+
|
|
556
|
+
# All threads should get the same instance
|
|
557
|
+
assert all(m is managers[0] for m in managers)
|
|
558
|
+
managers[0].shutdown()
|
|
559
|
+
reset_config_manager()
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
class TestConfigurationUpdate:
|
|
563
|
+
"""Tests for configuration update functionality."""
|
|
564
|
+
|
|
565
|
+
def test_update_configuration(self, manager):
|
|
566
|
+
"""update_configuration updates current config."""
|
|
567
|
+
new_config = FeedbackConfiguration(enabled=False, trigger_mode="never")
|
|
568
|
+
manager.update_configuration(new_config)
|
|
569
|
+
|
|
570
|
+
assert manager.get_configuration().enabled is False
|
|
571
|
+
assert manager.get_configuration().trigger_mode == "never"
|
|
572
|
+
|
|
573
|
+
def test_update_configuration_thread_safe(self, manager):
|
|
574
|
+
"""update_configuration is thread-safe."""
|
|
575
|
+
configs = [
|
|
576
|
+
FeedbackConfiguration(enabled=True, trigger_mode="always"),
|
|
577
|
+
FeedbackConfiguration(enabled=False, trigger_mode="never")
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
def update_config(config):
|
|
581
|
+
manager.update_configuration(config)
|
|
582
|
+
time.sleep(0.001)
|
|
583
|
+
|
|
584
|
+
threads = [threading.Thread(target=update_config, args=(configs[i % 2],)) for i in range(20)]
|
|
585
|
+
for t in threads:
|
|
586
|
+
t.start()
|
|
587
|
+
for t in threads:
|
|
588
|
+
t.join()
|
|
589
|
+
|
|
590
|
+
# Should have one of the configs (not corrupted)
|
|
591
|
+
final_config = manager.get_configuration()
|
|
592
|
+
assert final_config.trigger_mode in ["always", "never"]
|