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,580 @@
1
+ """
2
+ Tests for hot_reload.py module.
3
+
4
+ Validates file watching, change detection, callback invocation,
5
+ and hot-reload manager coordination.
6
+ Target: 95% coverage of 99 statements.
7
+ """
8
+
9
+ import pytest
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from unittest.mock import Mock, MagicMock, call
14
+ from devforgeai_cli.feedback.hot_reload import (
15
+ FileInfo,
16
+ ConfigFileWatcher,
17
+ HotReloadManager
18
+ )
19
+
20
+
21
+ @pytest.fixture
22
+ def temp_config_file(tmp_path):
23
+ """Provide a temporary configuration file."""
24
+ config_file = tmp_path / "feedback.yaml"
25
+ config_file.write_text("enabled: true\n")
26
+ return config_file
27
+
28
+
29
+ @pytest.fixture
30
+ def callback_mock():
31
+ """Provide a mock callback function."""
32
+ return Mock()
33
+
34
+
35
+ @pytest.fixture
36
+ def watcher(temp_config_file, callback_mock):
37
+ """Provide a ConfigFileWatcher instance."""
38
+ return ConfigFileWatcher(
39
+ config_file=temp_config_file,
40
+ on_change_callback=callback_mock,
41
+ poll_interval=0.1, # Fast polling for tests
42
+ detection_timeout=1.0
43
+ )
44
+
45
+
46
+ class TestFileInfo:
47
+ """Tests for FileInfo NamedTuple."""
48
+
49
+ def test_file_info_with_values(self):
50
+ """FileInfo stores mtime and size."""
51
+ info = FileInfo(mtime=123.456, size=1024)
52
+ assert info.mtime == 123.456
53
+ assert info.size == 1024
54
+
55
+ def test_file_info_with_none(self):
56
+ """FileInfo can have None values for missing files."""
57
+ info = FileInfo(mtime=None, size=None)
58
+ assert info.mtime is None
59
+ assert info.size is None
60
+
61
+
62
+ class TestConfigFileWatcherInitialization:
63
+ """Tests for ConfigFileWatcher initialization."""
64
+
65
+ def test_init_with_all_params(self, temp_config_file, callback_mock):
66
+ """ConfigFileWatcher accepts all parameters."""
67
+ watcher = ConfigFileWatcher(
68
+ config_file=temp_config_file,
69
+ on_change_callback=callback_mock,
70
+ poll_interval=0.5,
71
+ detection_timeout=5.0
72
+ )
73
+ assert watcher.config_file == temp_config_file
74
+ assert watcher.on_change_callback == callback_mock
75
+ assert watcher.poll_interval == 0.5
76
+ assert watcher.detection_timeout == 5.0
77
+
78
+ def test_init_default_poll_interval(self, temp_config_file, callback_mock):
79
+ """ConfigFileWatcher has default poll_interval of 0.5s."""
80
+ watcher = ConfigFileWatcher(
81
+ config_file=temp_config_file,
82
+ on_change_callback=callback_mock
83
+ )
84
+ assert watcher.poll_interval == 0.5
85
+
86
+ def test_init_default_detection_timeout(self, temp_config_file, callback_mock):
87
+ """ConfigFileWatcher has default detection_timeout of 5.0s."""
88
+ watcher = ConfigFileWatcher(
89
+ config_file=temp_config_file,
90
+ on_change_callback=callback_mock
91
+ )
92
+ assert watcher.detection_timeout == 5.0
93
+
94
+ def test_init_starts_not_running(self, watcher):
95
+ """ConfigFileWatcher is not running after initialization."""
96
+ assert watcher.is_running() is False
97
+
98
+ def test_init_no_watch_thread(self, watcher):
99
+ """ConfigFileWatcher has no watch thread initially."""
100
+ assert watcher._watch_thread is None
101
+
102
+
103
+ class TestConfigFileWatcherFileInfo:
104
+ """Tests for _get_file_info method."""
105
+
106
+ def test_get_file_info_file_exists(self, temp_config_file, watcher):
107
+ """_get_file_info returns mtime and size when file exists."""
108
+ info = watcher._get_file_info()
109
+ assert info.mtime is not None
110
+ assert info.size is not None
111
+ assert info.size > 0 # File has content
112
+
113
+ def test_get_file_info_file_not_exists(self, tmp_path, callback_mock):
114
+ """_get_file_info returns (None, None) when file doesn't exist."""
115
+ non_existent = tmp_path / "non-existent.yaml"
116
+ watcher = ConfigFileWatcher(
117
+ config_file=non_existent,
118
+ on_change_callback=callback_mock
119
+ )
120
+ info = watcher._get_file_info()
121
+ assert info.mtime is None
122
+ assert info.size is None
123
+
124
+
125
+ class TestConfigFileWatcherChangeDetection:
126
+ """Tests for _has_file_changed method."""
127
+
128
+ def test_has_file_changed_no_baseline(self, watcher):
129
+ """_has_file_changed returns False when no baseline set."""
130
+ watcher._last_file_info = None
131
+ current_info = FileInfo(mtime=123.456, size=1024)
132
+ assert watcher._has_file_changed(current_info) is False
133
+
134
+ def test_has_file_changed_mtime_changed(self, watcher):
135
+ """_has_file_changed returns True when mtime changes."""
136
+ watcher._last_file_info = FileInfo(mtime=100.0, size=1024)
137
+ current_info = FileInfo(mtime=200.0, size=1024)
138
+ assert watcher._has_file_changed(current_info) is True
139
+
140
+ def test_has_file_changed_size_changed(self, watcher):
141
+ """_has_file_changed returns True when size changes."""
142
+ watcher._last_file_info = FileInfo(mtime=100.0, size=1024)
143
+ current_info = FileInfo(mtime=100.0, size=2048)
144
+ assert watcher._has_file_changed(current_info) is True
145
+
146
+ def test_has_file_changed_no_change(self, watcher):
147
+ """_has_file_changed returns False when nothing changed."""
148
+ watcher._last_file_info = FileInfo(mtime=100.0, size=1024)
149
+ current_info = FileInfo(mtime=100.0, size=1024)
150
+ assert watcher._has_file_changed(current_info) is False
151
+
152
+ def test_has_file_changed_file_deleted(self, watcher):
153
+ """_has_file_changed handles file deletion."""
154
+ watcher._last_file_info = FileInfo(mtime=100.0, size=1024)
155
+ current_info = FileInfo(mtime=None, size=None)
156
+ assert watcher._has_file_changed(current_info) is False
157
+
158
+
159
+ class TestConfigFileWatcherStartStop:
160
+ """Tests for start() and stop() methods."""
161
+
162
+ def test_start_watcher(self, watcher):
163
+ """start() starts the file watcher."""
164
+ watcher.start()
165
+ time.sleep(0.15) # Give thread time to start
166
+ assert watcher.is_running() is True
167
+ watcher.stop()
168
+
169
+ def test_start_watcher_twice_idempotent(self, watcher):
170
+ """start() is idempotent (calling twice doesn't create multiple threads)."""
171
+ watcher.start()
172
+ time.sleep(0.05)
173
+ thread1 = watcher._watch_thread
174
+
175
+ watcher.start() # Call again
176
+ thread2 = watcher._watch_thread
177
+
178
+ assert thread1 is thread2
179
+ watcher.stop()
180
+
181
+ def test_stop_watcher(self, watcher):
182
+ """stop() stops the file watcher."""
183
+ watcher.start()
184
+ time.sleep(0.15)
185
+ watcher.stop()
186
+ assert watcher.is_running() is False
187
+
188
+ def test_stop_watcher_not_running_idempotent(self, watcher):
189
+ """stop() is idempotent (calling when not running is safe)."""
190
+ assert watcher.is_running() is False
191
+ watcher.stop() # Should not error
192
+ assert watcher.is_running() is False
193
+
194
+ def test_is_running_after_start(self, watcher):
195
+ """is_running() returns True after start()."""
196
+ watcher.start()
197
+ time.sleep(0.05)
198
+ assert watcher.is_running() is True
199
+ watcher.stop()
200
+
201
+ def test_is_running_after_stop(self, watcher):
202
+ """is_running() returns False after stop()."""
203
+ watcher.start()
204
+ time.sleep(0.05)
205
+ watcher.stop()
206
+ time.sleep(0.05)
207
+ assert watcher.is_running() is False
208
+
209
+
210
+ class TestConfigFileWatcherCallbacks:
211
+ """Tests for callback invocation on file changes (AC-9)."""
212
+
213
+ def test_callback_invoked_on_file_change(self, temp_config_file, callback_mock):
214
+ """Callback is called when file changes (AC-9)."""
215
+ watcher = ConfigFileWatcher(
216
+ config_file=temp_config_file,
217
+ on_change_callback=callback_mock,
218
+ poll_interval=0.1
219
+ )
220
+ watcher.start()
221
+ time.sleep(0.15) # Let watcher initialize
222
+
223
+ # Modify file
224
+ temp_config_file.write_text("enabled: false\n")
225
+ time.sleep(0.5) # Wait for detection (≤5s per AC-9)
226
+
227
+ watcher.stop()
228
+
229
+ # Callback should have been called
230
+ assert callback_mock.call_count >= 1
231
+ callback_mock.assert_called_with(temp_config_file)
232
+
233
+ def test_callback_not_invoked_when_no_change(self, temp_config_file, callback_mock):
234
+ """Callback is NOT called when file doesn't change."""
235
+ watcher = ConfigFileWatcher(
236
+ config_file=temp_config_file,
237
+ on_change_callback=callback_mock,
238
+ poll_interval=0.1
239
+ )
240
+ watcher.start()
241
+ time.sleep(0.3) # Let it poll a few times
242
+ watcher.stop()
243
+
244
+ # Callback should not be called (file didn't change)
245
+ assert callback_mock.call_count == 0
246
+
247
+ def test_callback_survives_exception(self, temp_config_file):
248
+ """Watcher continues running even if callback raises exception."""
249
+ exception_callback = Mock(side_effect=Exception("Callback error"))
250
+
251
+ watcher = ConfigFileWatcher(
252
+ config_file=temp_config_file,
253
+ on_change_callback=exception_callback,
254
+ poll_interval=0.1
255
+ )
256
+ watcher.start()
257
+ time.sleep(0.15)
258
+
259
+ # Modify file
260
+ temp_config_file.write_text("enabled: false\n")
261
+ time.sleep(0.5)
262
+
263
+ # Watcher should still be running despite callback exception
264
+ assert watcher.is_running() is True
265
+ watcher.stop()
266
+
267
+
268
+ class TestHotReloadManagerInitialization:
269
+ """Tests for HotReloadManager initialization."""
270
+
271
+ def test_init_hot_reload_manager(self, temp_config_file):
272
+ """HotReloadManager accepts config file and callback."""
273
+ callback = Mock()
274
+ manager = HotReloadManager(
275
+ config_file=temp_config_file,
276
+ load_config_callback=callback
277
+ )
278
+ assert manager.config_file == temp_config_file
279
+ assert manager.load_config_callback == callback
280
+
281
+ def test_init_no_watcher_created(self, temp_config_file):
282
+ """HotReloadManager doesn't create watcher on init."""
283
+ callback = Mock()
284
+ manager = HotReloadManager(
285
+ config_file=temp_config_file,
286
+ load_config_callback=callback
287
+ )
288
+ assert manager._watcher is None
289
+
290
+
291
+ class TestHotReloadManagerLifecycle:
292
+ """Tests for HotReloadManager start/stop lifecycle."""
293
+
294
+ def test_start_creates_watcher(self, temp_config_file):
295
+ """start() creates ConfigFileWatcher instance."""
296
+ callback = Mock()
297
+ manager = HotReloadManager(
298
+ config_file=temp_config_file,
299
+ load_config_callback=callback
300
+ )
301
+ manager.start()
302
+ time.sleep(0.1)
303
+
304
+ assert manager._watcher is not None
305
+ assert manager.is_running() is True
306
+
307
+ manager.stop()
308
+
309
+ def test_start_idempotent(self, temp_config_file):
310
+ """start() is idempotent (doesn't create multiple watchers)."""
311
+ callback = Mock()
312
+ manager = HotReloadManager(
313
+ config_file=temp_config_file,
314
+ load_config_callback=callback
315
+ )
316
+ manager.start()
317
+ time.sleep(0.05)
318
+ watcher1 = manager._watcher
319
+
320
+ manager.start() # Call again
321
+ watcher2 = manager._watcher
322
+
323
+ assert watcher1 is watcher2
324
+ manager.stop()
325
+
326
+ def test_stop_stops_watcher(self, temp_config_file):
327
+ """stop() stops the watcher."""
328
+ callback = Mock()
329
+ manager = HotReloadManager(
330
+ config_file=temp_config_file,
331
+ load_config_callback=callback
332
+ )
333
+ manager.start()
334
+ time.sleep(0.1)
335
+ manager.stop()
336
+
337
+ assert manager._watcher is None
338
+ assert manager.is_running() is False
339
+
340
+ def test_is_running_after_start(self, temp_config_file):
341
+ """is_running() returns True after start()."""
342
+ callback = Mock()
343
+ manager = HotReloadManager(
344
+ config_file=temp_config_file,
345
+ load_config_callback=callback
346
+ )
347
+ manager.start()
348
+ time.sleep(0.1)
349
+ assert manager.is_running() is True
350
+ manager.stop()
351
+
352
+ def test_is_running_after_stop(self, temp_config_file):
353
+ """is_running() returns False after stop()."""
354
+ callback = Mock()
355
+ manager = HotReloadManager(
356
+ config_file=temp_config_file,
357
+ load_config_callback=callback
358
+ )
359
+ manager.start()
360
+ time.sleep(0.1)
361
+ manager.stop()
362
+ assert manager.is_running() is False
363
+
364
+
365
+ class TestHotReloadManagerConfigManagement:
366
+ """Tests for configuration get/set methods."""
367
+
368
+ def test_get_current_config_none(self, temp_config_file):
369
+ """get_current_config returns None initially."""
370
+ callback = Mock()
371
+ manager = HotReloadManager(
372
+ config_file=temp_config_file,
373
+ load_config_callback=callback
374
+ )
375
+ assert manager.get_current_config() is None
376
+
377
+ def test_set_current_config(self, temp_config_file):
378
+ """set_current_config stores configuration."""
379
+ callback = Mock()
380
+ manager = HotReloadManager(
381
+ config_file=temp_config_file,
382
+ load_config_callback=callback
383
+ )
384
+ config_obj = {"enabled": True}
385
+ manager.set_current_config(config_obj)
386
+ assert manager.get_current_config() == config_obj
387
+
388
+ def test_get_current_config_after_set(self, temp_config_file):
389
+ """get_current_config returns set configuration."""
390
+ callback = Mock()
391
+ manager = HotReloadManager(
392
+ config_file=temp_config_file,
393
+ load_config_callback=callback
394
+ )
395
+ config1 = {"enabled": True}
396
+ config2 = {"enabled": False}
397
+
398
+ manager.set_current_config(config1)
399
+ assert manager.get_current_config() == config1
400
+
401
+ manager.set_current_config(config2)
402
+ assert manager.get_current_config() == config2
403
+
404
+
405
+ class TestHotReloadManagerConfigReload:
406
+ """Tests for configuration reloading on file changes (AC-9)."""
407
+
408
+ def test_on_config_change_calls_callback(self, temp_config_file):
409
+ """_on_config_change invokes load_config_callback."""
410
+ new_config = {"enabled": False, "trigger_mode": "never"}
411
+ callback = Mock(return_value=new_config)
412
+
413
+ manager = HotReloadManager(
414
+ config_file=temp_config_file,
415
+ load_config_callback=callback
416
+ )
417
+ manager._on_config_change(temp_config_file)
418
+
419
+ assert callback.call_count == 1
420
+
421
+ def test_on_config_change_updates_current_config(self, temp_config_file):
422
+ """_on_config_change updates current configuration."""
423
+ new_config = {"enabled": False, "trigger_mode": "never"}
424
+ callback = Mock(return_value=new_config)
425
+
426
+ manager = HotReloadManager(
427
+ config_file=temp_config_file,
428
+ load_config_callback=callback
429
+ )
430
+ manager._on_config_change(temp_config_file)
431
+
432
+ assert manager.get_current_config() == new_config
433
+
434
+ def test_on_config_change_exception_keeps_previous_config(self, temp_config_file):
435
+ """_on_config_change keeps previous config when callback raises exception."""
436
+ old_config = {"enabled": True}
437
+ callback = Mock(side_effect=Exception("Load error"))
438
+
439
+ manager = HotReloadManager(
440
+ config_file=temp_config_file,
441
+ load_config_callback=callback
442
+ )
443
+ manager.set_current_config(old_config)
444
+
445
+ # Trigger change - should catch exception
446
+ manager._on_config_change(temp_config_file)
447
+
448
+ # Previous config should be preserved
449
+ assert manager.get_current_config() == old_config
450
+
451
+
452
+ class TestHotReloadIntegration:
453
+ """Integration tests for hot-reload functionality (AC-9)."""
454
+
455
+ def test_end_to_end_file_change_detected(self, temp_config_file):
456
+ """End-to-end: File change triggers reload within 5s (AC-9)."""
457
+ new_config = {"enabled": False, "trigger_mode": "never"}
458
+ callback = Mock(return_value=new_config)
459
+
460
+ manager = HotReloadManager(
461
+ config_file=temp_config_file,
462
+ load_config_callback=callback
463
+ )
464
+ manager.start()
465
+ time.sleep(0.2) # Let watcher initialize
466
+
467
+ # Modify file
468
+ temp_config_file.write_text("enabled: false\ntrigger_mode: never\n")
469
+
470
+ # Wait up to 5 seconds for detection (AC-9 requirement: ≤5s)
471
+ timeout = 5.0
472
+ start_time = time.time()
473
+ while time.time() - start_time < timeout:
474
+ if callback.call_count > 0:
475
+ break
476
+ time.sleep(0.1)
477
+
478
+ manager.stop()
479
+
480
+ # Callback should have been invoked within 5s
481
+ assert callback.call_count >= 1
482
+ assert manager.get_current_config() == new_config
483
+
484
+ def test_end_to_end_multiple_changes(self, temp_config_file):
485
+ """End-to-end: Multiple file changes are detected."""
486
+ configs = [
487
+ {"enabled": False},
488
+ {"enabled": True},
489
+ {"trigger_mode": "always"}
490
+ ]
491
+ callback = Mock(side_effect=configs)
492
+
493
+ manager = HotReloadManager(
494
+ config_file=temp_config_file,
495
+ load_config_callback=callback
496
+ )
497
+ manager.start()
498
+ time.sleep(0.2)
499
+
500
+ # Make 3 file changes
501
+ temp_config_file.write_text("enabled: false\n")
502
+ time.sleep(0.3)
503
+
504
+ temp_config_file.write_text("enabled: true\n")
505
+ time.sleep(0.3)
506
+
507
+ temp_config_file.write_text("trigger_mode: always\n")
508
+ time.sleep(0.3)
509
+
510
+ manager.stop()
511
+
512
+ # At least one change should be detected
513
+ assert callback.call_count >= 1
514
+
515
+
516
+ class TestHotReloadManagerThreadSafety:
517
+ """Tests for thread-safe operations."""
518
+
519
+ def test_set_current_config_thread_safe(self, temp_config_file):
520
+ """set_current_config is thread-safe."""
521
+ callback = Mock()
522
+ manager = HotReloadManager(
523
+ config_file=temp_config_file,
524
+ load_config_callback=callback
525
+ )
526
+
527
+ configs = [{"id": i} for i in range(20)]
528
+
529
+ def set_config(config):
530
+ manager.set_current_config(config)
531
+ time.sleep(0.001)
532
+
533
+ threads = [threading.Thread(target=set_config, args=(configs[i],)) for i in range(20)]
534
+ for t in threads:
535
+ t.start()
536
+ for t in threads:
537
+ t.join()
538
+
539
+ # Should have one of the configs (not corrupted)
540
+ final_config = manager.get_current_config()
541
+ assert isinstance(final_config, dict)
542
+ assert "id" in final_config
543
+
544
+ def test_get_current_config_thread_safe(self, temp_config_file):
545
+ """get_current_config is thread-safe during concurrent access."""
546
+ callback = Mock()
547
+ manager = HotReloadManager(
548
+ config_file=temp_config_file,
549
+ load_config_callback=callback
550
+ )
551
+ manager.set_current_config({"enabled": True})
552
+
553
+ results = []
554
+
555
+ def read_config():
556
+ for _ in range(5):
557
+ results.append(manager.get_current_config())
558
+ time.sleep(0.001)
559
+
560
+ threads = [threading.Thread(target=read_config) for _ in range(5)]
561
+ for t in threads:
562
+ t.start()
563
+ for t in threads:
564
+ t.join()
565
+
566
+ # All reads should see consistent value
567
+ assert all(r == {"enabled": True} for r in results)
568
+
569
+
570
+ class TestCleanup:
571
+ """Tests for cleanup after testing."""
572
+
573
+ def test_cleanup_watcher_stops(self, watcher):
574
+ """Ensure watcher stops for cleanup."""
575
+ watcher.start()
576
+ time.sleep(0.1)
577
+ watcher.stop()
578
+ # Short delay to ensure thread fully stopped
579
+ time.sleep(0.2)
580
+ assert watcher.is_running() is False