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.
- package/CLAUDE.md +120 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -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/devforgeai-qa/SKILL.md +1 -1
- package/src/claude/skills/researching-market/SKILL.md +2 -1
- package/src/cli/lib/copier.js +13 -1
- 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,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
|