devforgeai 1.0.4 → 1.0.6

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.
Files changed (134) hide show
  1. package/CLAUDE.md +120 -0
  2. package/package.json +9 -1
  3. package/src/CLAUDE.md +699 -0
  4. package/src/claude/scripts/README.md +396 -0
  5. package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
  6. package/src/claude/scripts/check-hooks-fast.sh +70 -0
  7. package/src/claude/scripts/devforgeai-validate +6 -0
  8. package/src/claude/scripts/devforgeai_cli/README.md +531 -0
  9. package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
  10. package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
  11. package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
  12. package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
  13. package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
  14. package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
  15. package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
  16. package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
  17. package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
  18. package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
  19. package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
  20. package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
  21. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
  22. package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
  23. package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
  24. package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
  25. package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
  26. package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
  27. package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
  28. package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
  29. package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
  30. package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
  31. package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
  32. package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
  33. package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
  34. package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
  35. package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
  36. package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
  37. package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
  38. package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
  39. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
  40. package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
  41. package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
  42. package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
  43. package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
  44. package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
  45. package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
  46. package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
  47. package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
  48. package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
  49. package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
  50. package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
  51. package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
  52. package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
  53. package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
  54. package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
  55. package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
  56. package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
  57. package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
  58. package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
  59. package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
  60. package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
  61. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
  62. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
  63. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
  64. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
  65. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
  66. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
  67. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
  68. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
  69. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
  70. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
  71. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
  72. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
  73. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
  74. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
  75. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
  76. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
  77. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
  78. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
  79. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
  80. package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
  81. package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
  82. package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
  83. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
  84. package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
  85. package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
  86. package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
  87. package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
  88. package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
  89. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
  90. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
  91. package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
  92. package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
  93. package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
  94. package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
  95. package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
  96. package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
  97. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
  98. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
  99. package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
  100. package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
  101. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
  102. package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
  103. package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
  104. package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
  105. package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
  106. package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
  107. package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
  108. package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
  109. package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
  110. package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
  111. package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
  112. package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
  113. package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
  114. package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
  115. package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
  116. package/src/claude/scripts/install_hooks.sh +186 -0
  117. package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
  118. package/src/claude/scripts/migrate-ac-headers.sh +122 -0
  119. package/src/claude/scripts/plan_file_kb.sh +704 -0
  120. package/src/claude/scripts/requirements.txt +8 -0
  121. package/src/claude/scripts/session_catalog.sh +543 -0
  122. package/src/claude/scripts/setup.py +55 -0
  123. package/src/claude/scripts/start-devforgeai.sh +16 -0
  124. package/src/claude/scripts/statusline.sh +27 -0
  125. package/src/claude/scripts/validate_deferrals.py +344 -0
  126. package/src/claude/skills/devforgeai-qa/SKILL.md +1 -1
  127. package/src/claude/skills/researching-market/SKILL.md +2 -1
  128. package/src/cli/lib/copier.js +13 -1
  129. package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
  130. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
  131. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
  132. package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
  133. package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
  134. 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"]