devforgeai 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +120 -0
- package/bin/devforgeai.js +0 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/hooks/phase-completion-gate.sh +0 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/detect_anti_patterns.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_all_context.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_architecture.cpython-312.pyc +0 -0
- package/src/claude/skills/designing-systems/scripts/__pycache__/validate_dependencies.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/__pycache__/migrate_story_v1_to_v2.cpython-312.pyc +0 -0
- package/src/claude/skills/devforgeai-story-creation/scripts/tests/__pycache__/measure_accuracy.cpython-312.pyc +0 -0
|
@@ -0,0 +1,2187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
STORY-253: PhaseState Module Tests
|
|
3
|
+
|
|
4
|
+
Test suite for PhaseState class following TDD Red phase principles.
|
|
5
|
+
These tests are designed to FAIL initially because the module doesn't exist yet
|
|
6
|
+
at `.claude/scripts/devforgeai_cli/phase_state.py`.
|
|
7
|
+
|
|
8
|
+
Test Coverage Target: 95%+ for business logic
|
|
9
|
+
|
|
10
|
+
Test Framework: pytest (per tech-stack.md)
|
|
11
|
+
Test Pattern: AAA (Arrange, Act, Assert)
|
|
12
|
+
Test Naming: test_<function>_<scenario>_<expected>
|
|
13
|
+
|
|
14
|
+
Acceptance Criteria Mapping:
|
|
15
|
+
- AC#1: PhaseState class initialization and path resolution
|
|
16
|
+
- AC#2: Create new phase state file with complete schema
|
|
17
|
+
- AC#3: Idempotent state file creation
|
|
18
|
+
- AC#4: Read existing phase state
|
|
19
|
+
- AC#5: Read returns None for non-existent state
|
|
20
|
+
- AC#6: Complete phase with sequential enforcement
|
|
21
|
+
- AC#7: Phase transition validation (sequential order only)
|
|
22
|
+
- AC#8: Record subagent invocation
|
|
23
|
+
- AC#9: Add workflow observation
|
|
24
|
+
- AC#10: Input validation for story ID format
|
|
25
|
+
- AC#11: Input validation for phase ID
|
|
26
|
+
- AC#12: State file path helper method
|
|
27
|
+
|
|
28
|
+
Edge Cases (from story):
|
|
29
|
+
- Corrupted JSON state file
|
|
30
|
+
- Concurrent write protection (platform-aware)
|
|
31
|
+
- Missing workflows directory
|
|
32
|
+
- Duplicate subagent recording
|
|
33
|
+
- Phase 10 completion boundary
|
|
34
|
+
- Empty observation note
|
|
35
|
+
- Invalid observation category
|
|
36
|
+
- Invalid observation severity
|
|
37
|
+
- Atomic file writes
|
|
38
|
+
- Empty state file
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import sys
|
|
44
|
+
import tempfile
|
|
45
|
+
import threading
|
|
46
|
+
import time
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Generator
|
|
50
|
+
from unittest.mock import MagicMock, patch
|
|
51
|
+
|
|
52
|
+
import pytest
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# =============================================================================
|
|
56
|
+
# Test Setup and Fixtures
|
|
57
|
+
# =============================================================================
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
def temp_project_root() -> Generator[Path, None, None]:
|
|
62
|
+
"""Create a temporary project root directory for testing."""
|
|
63
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
64
|
+
project_root = Path(tmpdir)
|
|
65
|
+
yield project_root
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def phase_state(temp_project_root: Path):
|
|
70
|
+
"""Create a PhaseState instance with temporary project root."""
|
|
71
|
+
# Import will fail until module is created - this is expected for TDD Red
|
|
72
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
73
|
+
return PhaseState(project_root=temp_project_root)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def phase_state_with_existing_file(temp_project_root: Path):
|
|
78
|
+
"""Create PhaseState instance with pre-existing state file.
|
|
79
|
+
|
|
80
|
+
STORY-307: Updated to populate subagents_required from PHASE_REQUIRED_SUBAGENTS
|
|
81
|
+
constant, matching STORY-306 subagent enforcement feature.
|
|
82
|
+
"""
|
|
83
|
+
from devforgeai_cli.phase_state import PhaseState, PHASE_REQUIRED_SUBAGENTS
|
|
84
|
+
|
|
85
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
86
|
+
|
|
87
|
+
# Create workflows directory and state file
|
|
88
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
89
|
+
workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
90
|
+
|
|
91
|
+
# Build phases dict with populated subagents_required (AC1)
|
|
92
|
+
# Includes decimal phases 4.5 and 5.5 for AC verification
|
|
93
|
+
valid_phases = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
|
|
94
|
+
phases = {}
|
|
95
|
+
for phase in valid_phases:
|
|
96
|
+
# Convert tuples to lists for JSON serialization (Phase 03 OR logic)
|
|
97
|
+
required = []
|
|
98
|
+
for item in PHASE_REQUIRED_SUBAGENTS.get(phase, []):
|
|
99
|
+
if isinstance(item, tuple):
|
|
100
|
+
required.append(list(item)) # OR group as list
|
|
101
|
+
else:
|
|
102
|
+
required.append(item)
|
|
103
|
+
|
|
104
|
+
phases[phase] = {
|
|
105
|
+
"status": "pending",
|
|
106
|
+
"subagents_required": required,
|
|
107
|
+
"subagents_invoked": []
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Create a valid state file
|
|
111
|
+
state = {
|
|
112
|
+
"story_id": "STORY-001",
|
|
113
|
+
"current_phase": "01",
|
|
114
|
+
"workflow_started": "2026-01-12T12:00:00Z",
|
|
115
|
+
"blocking_status": False,
|
|
116
|
+
"phases": phases,
|
|
117
|
+
"validation_errors": [],
|
|
118
|
+
"observations": []
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
state_path = workflows_dir / "STORY-001-phase-state.json"
|
|
122
|
+
state_path.write_text(json.dumps(state, indent=2))
|
|
123
|
+
|
|
124
|
+
return ps
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.fixture
|
|
128
|
+
def corrupted_state_file(temp_project_root: Path):
|
|
129
|
+
"""Create a corrupted state file for error handling tests."""
|
|
130
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
131
|
+
workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
|
|
133
|
+
state_path = workflows_dir / "STORY-002-phase-state.json"
|
|
134
|
+
state_path.write_text("{invalid json content")
|
|
135
|
+
|
|
136
|
+
return temp_project_root
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.fixture
|
|
140
|
+
def empty_state_file(temp_project_root: Path):
|
|
141
|
+
"""Create an empty state file for edge case testing."""
|
|
142
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
143
|
+
workflows_dir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
|
|
145
|
+
state_path = workflows_dir / "STORY-003-phase-state.json"
|
|
146
|
+
state_path.write_text("")
|
|
147
|
+
|
|
148
|
+
return temp_project_root
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# =============================================================================
|
|
152
|
+
# AC#1: PhaseState class initialization and path resolution
|
|
153
|
+
# =============================================================================
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class TestAC1Initialization:
|
|
157
|
+
"""Tests for AC#1: PhaseState class initialization and path resolution."""
|
|
158
|
+
|
|
159
|
+
def test_init_accepts_project_root_path(self, temp_project_root: Path):
|
|
160
|
+
"""
|
|
161
|
+
Given: A project root directory path
|
|
162
|
+
When: PhaseState(project_root=Path("/path/to/project")) is instantiated
|
|
163
|
+
Then: The instance stores the project root
|
|
164
|
+
"""
|
|
165
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
166
|
+
|
|
167
|
+
# Arrange
|
|
168
|
+
project_root = temp_project_root
|
|
169
|
+
|
|
170
|
+
# Act
|
|
171
|
+
ps = PhaseState(project_root=project_root)
|
|
172
|
+
|
|
173
|
+
# Assert
|
|
174
|
+
assert ps.project_root == project_root
|
|
175
|
+
|
|
176
|
+
def test_init_resolves_workflows_dir_correctly(self, temp_project_root: Path):
|
|
177
|
+
"""
|
|
178
|
+
Given: A project root directory path
|
|
179
|
+
When: PhaseState is instantiated
|
|
180
|
+
Then: workflows_dir resolves to {project_root}/devforgeai/workflows/
|
|
181
|
+
"""
|
|
182
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
183
|
+
|
|
184
|
+
# Arrange
|
|
185
|
+
project_root = temp_project_root
|
|
186
|
+
expected_workflows_dir = project_root / "devforgeai" / "workflows"
|
|
187
|
+
|
|
188
|
+
# Act
|
|
189
|
+
ps = PhaseState(project_root=project_root)
|
|
190
|
+
|
|
191
|
+
# Assert
|
|
192
|
+
assert ps.workflows_dir == expected_workflows_dir
|
|
193
|
+
|
|
194
|
+
def test_init_accepts_string_path_converts_to_path(self, temp_project_root: Path):
|
|
195
|
+
"""
|
|
196
|
+
Given: A string path to project root
|
|
197
|
+
When: PhaseState is instantiated
|
|
198
|
+
Then: The path is converted to Path object
|
|
199
|
+
"""
|
|
200
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
201
|
+
|
|
202
|
+
# Arrange
|
|
203
|
+
project_root_str = str(temp_project_root)
|
|
204
|
+
|
|
205
|
+
# Act
|
|
206
|
+
ps = PhaseState(project_root=Path(project_root_str))
|
|
207
|
+
|
|
208
|
+
# Assert
|
|
209
|
+
assert isinstance(ps.project_root, Path)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# =============================================================================
|
|
213
|
+
# AC#2: Create new phase state file with complete schema
|
|
214
|
+
# =============================================================================
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class TestAC2CreateNewState:
|
|
218
|
+
"""Tests for AC#2: Create new phase state file with complete schema."""
|
|
219
|
+
|
|
220
|
+
def test_create_creates_json_file_at_correct_path(self, phase_state, temp_project_root: Path):
|
|
221
|
+
"""
|
|
222
|
+
Given: A valid story ID
|
|
223
|
+
When: create(story_id="STORY-001") is called
|
|
224
|
+
Then: A JSON file is created at devforgeai/workflows/STORY-001-phase-state.json
|
|
225
|
+
"""
|
|
226
|
+
# Arrange
|
|
227
|
+
story_id = "STORY-001"
|
|
228
|
+
expected_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
|
|
229
|
+
|
|
230
|
+
# Act
|
|
231
|
+
phase_state.create(story_id)
|
|
232
|
+
|
|
233
|
+
# Assert
|
|
234
|
+
assert expected_path.exists()
|
|
235
|
+
|
|
236
|
+
def test_create_returns_state_with_story_id(self, phase_state):
|
|
237
|
+
"""
|
|
238
|
+
Given: A valid story ID
|
|
239
|
+
When: create() is called
|
|
240
|
+
Then: Returned state contains story_id field
|
|
241
|
+
"""
|
|
242
|
+
# Arrange
|
|
243
|
+
story_id = "STORY-001"
|
|
244
|
+
|
|
245
|
+
# Act
|
|
246
|
+
state = phase_state.create(story_id)
|
|
247
|
+
|
|
248
|
+
# Assert
|
|
249
|
+
assert state["story_id"] == story_id
|
|
250
|
+
|
|
251
|
+
def test_create_returns_state_with_current_phase_01(self, phase_state):
|
|
252
|
+
"""
|
|
253
|
+
Given: A new state file creation
|
|
254
|
+
When: create() is called
|
|
255
|
+
Then: current_phase is "01"
|
|
256
|
+
"""
|
|
257
|
+
# Arrange & Act
|
|
258
|
+
state = phase_state.create("STORY-001")
|
|
259
|
+
|
|
260
|
+
# Assert
|
|
261
|
+
assert state["current_phase"] == "01"
|
|
262
|
+
|
|
263
|
+
def test_create_returns_state_with_workflow_started_timestamp(self, phase_state):
|
|
264
|
+
"""
|
|
265
|
+
Given: A new state file creation
|
|
266
|
+
When: create() is called
|
|
267
|
+
Then: workflow_started contains ISO-8601 UTC timestamp
|
|
268
|
+
"""
|
|
269
|
+
# Arrange & Act
|
|
270
|
+
state = phase_state.create("STORY-001")
|
|
271
|
+
|
|
272
|
+
# Assert
|
|
273
|
+
assert "workflow_started" in state
|
|
274
|
+
# Validate ISO-8601 format with Z suffix
|
|
275
|
+
assert state["workflow_started"].endswith("Z")
|
|
276
|
+
# Should be parseable as ISO-8601
|
|
277
|
+
datetime.fromisoformat(state["workflow_started"].replace("Z", "+00:00"))
|
|
278
|
+
|
|
279
|
+
def test_create_returns_state_with_blocking_status_false(self, phase_state):
|
|
280
|
+
"""
|
|
281
|
+
Given: A new state file creation
|
|
282
|
+
When: create() is called
|
|
283
|
+
Then: blocking_status is false
|
|
284
|
+
"""
|
|
285
|
+
# Arrange & Act
|
|
286
|
+
state = phase_state.create("STORY-001")
|
|
287
|
+
|
|
288
|
+
# Assert
|
|
289
|
+
assert state["blocking_status"] is False
|
|
290
|
+
|
|
291
|
+
def test_create_returns_state_with_all_12_phases(self, phase_state):
|
|
292
|
+
"""
|
|
293
|
+
Given: A new state file creation
|
|
294
|
+
When: create() is called
|
|
295
|
+
Then: phases object contains all 12 valid phases (01-10 plus 4.5, 5.5)
|
|
296
|
+
|
|
297
|
+
STORY-307: Updated to expect 12 phases including decimal phases for AC verification.
|
|
298
|
+
"""
|
|
299
|
+
# Arrange & Act
|
|
300
|
+
state = phase_state.create("STORY-001")
|
|
301
|
+
|
|
302
|
+
# Assert
|
|
303
|
+
expected_phases = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
|
|
304
|
+
assert list(state["phases"].keys()) == expected_phases
|
|
305
|
+
|
|
306
|
+
def test_create_phases_have_status_subagents_required_invoked(self, phase_state):
|
|
307
|
+
"""
|
|
308
|
+
Given: A new state file creation
|
|
309
|
+
When: create() is called
|
|
310
|
+
Then: Each phase has status, subagents_required, and subagents_invoked
|
|
311
|
+
"""
|
|
312
|
+
# Arrange & Act
|
|
313
|
+
state = phase_state.create("STORY-001")
|
|
314
|
+
|
|
315
|
+
# Assert
|
|
316
|
+
for phase_id in state["phases"]:
|
|
317
|
+
phase = state["phases"][phase_id]
|
|
318
|
+
assert "status" in phase
|
|
319
|
+
assert "subagents_required" in phase
|
|
320
|
+
assert "subagents_invoked" in phase
|
|
321
|
+
|
|
322
|
+
def test_create_returns_state_with_empty_validation_errors(self, phase_state):
|
|
323
|
+
"""
|
|
324
|
+
Given: A new state file creation
|
|
325
|
+
When: create() is called
|
|
326
|
+
Then: validation_errors is an empty array
|
|
327
|
+
"""
|
|
328
|
+
# Arrange & Act
|
|
329
|
+
state = phase_state.create("STORY-001")
|
|
330
|
+
|
|
331
|
+
# Assert
|
|
332
|
+
assert state["validation_errors"] == []
|
|
333
|
+
|
|
334
|
+
def test_create_returns_state_with_empty_observations(self, phase_state):
|
|
335
|
+
"""
|
|
336
|
+
Given: A new state file creation
|
|
337
|
+
When: create() is called
|
|
338
|
+
Then: observations is an empty array
|
|
339
|
+
"""
|
|
340
|
+
# Arrange & Act
|
|
341
|
+
state = phase_state.create("STORY-001")
|
|
342
|
+
|
|
343
|
+
# Assert
|
|
344
|
+
assert state["observations"] == []
|
|
345
|
+
|
|
346
|
+
def test_create_creates_directories_if_missing(self, temp_project_root: Path):
|
|
347
|
+
"""
|
|
348
|
+
Given: A fresh project without devforgeai/workflows/ directory
|
|
349
|
+
When: create() is called
|
|
350
|
+
Then: Directories are created automatically
|
|
351
|
+
"""
|
|
352
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
353
|
+
|
|
354
|
+
# Arrange
|
|
355
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
356
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
357
|
+
assert not workflows_dir.exists()
|
|
358
|
+
|
|
359
|
+
# Act
|
|
360
|
+
ps.create("STORY-001")
|
|
361
|
+
|
|
362
|
+
# Assert
|
|
363
|
+
assert workflows_dir.exists()
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# =============================================================================
|
|
367
|
+
# AC#3: Idempotent state file creation
|
|
368
|
+
# =============================================================================
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class TestAC3IdempotentCreation:
|
|
372
|
+
"""Tests for AC#3: Idempotent state file creation."""
|
|
373
|
+
|
|
374
|
+
def test_create_existing_file_returns_existing_state(
|
|
375
|
+
self, phase_state_with_existing_file, temp_project_root: Path
|
|
376
|
+
):
|
|
377
|
+
"""
|
|
378
|
+
Given: A state file already exists for STORY-001
|
|
379
|
+
When: create(story_id="STORY-001") is called again
|
|
380
|
+
Then: The existing state is returned without modification
|
|
381
|
+
"""
|
|
382
|
+
# Arrange
|
|
383
|
+
ps = phase_state_with_existing_file
|
|
384
|
+
original_state = ps.read("STORY-001")
|
|
385
|
+
original_timestamp = original_state["workflow_started"]
|
|
386
|
+
|
|
387
|
+
# Act
|
|
388
|
+
returned_state = ps.create("STORY-001")
|
|
389
|
+
|
|
390
|
+
# Assert
|
|
391
|
+
assert returned_state["workflow_started"] == original_timestamp
|
|
392
|
+
|
|
393
|
+
def test_create_existing_file_does_not_overwrite(
|
|
394
|
+
self, phase_state_with_existing_file, temp_project_root: Path
|
|
395
|
+
):
|
|
396
|
+
"""
|
|
397
|
+
Given: A state file already exists for STORY-001
|
|
398
|
+
When: create() is called again
|
|
399
|
+
Then: The file is not modified (no overwrite)
|
|
400
|
+
"""
|
|
401
|
+
# Arrange
|
|
402
|
+
ps = phase_state_with_existing_file
|
|
403
|
+
state_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
|
|
404
|
+
original_mtime = state_path.stat().st_mtime
|
|
405
|
+
time.sleep(0.1) # Ensure time difference
|
|
406
|
+
|
|
407
|
+
# Act
|
|
408
|
+
ps.create("STORY-001")
|
|
409
|
+
|
|
410
|
+
# Assert
|
|
411
|
+
new_mtime = state_path.stat().st_mtime
|
|
412
|
+
assert new_mtime == original_mtime
|
|
413
|
+
|
|
414
|
+
def test_create_idempotent_consecutive_calls_same_state(self, phase_state):
|
|
415
|
+
"""
|
|
416
|
+
Given: Multiple consecutive create calls
|
|
417
|
+
When: create() is called twice
|
|
418
|
+
Then: Both return the same state (idempotent)
|
|
419
|
+
"""
|
|
420
|
+
# Arrange & Act
|
|
421
|
+
state1 = phase_state.create("STORY-001")
|
|
422
|
+
state2 = phase_state.create("STORY-001")
|
|
423
|
+
|
|
424
|
+
# Assert
|
|
425
|
+
assert state1["story_id"] == state2["story_id"]
|
|
426
|
+
assert state1["workflow_started"] == state2["workflow_started"]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# =============================================================================
|
|
430
|
+
# AC#4: Read existing phase state
|
|
431
|
+
# =============================================================================
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
class TestAC4ReadExistingState:
|
|
435
|
+
"""Tests for AC#4: Read existing phase state."""
|
|
436
|
+
|
|
437
|
+
def test_read_returns_complete_state_dict(self, phase_state_with_existing_file):
|
|
438
|
+
"""
|
|
439
|
+
Given: A state file exists for STORY-001
|
|
440
|
+
When: read(story_id="STORY-001") is called
|
|
441
|
+
Then: The method returns the complete state dictionary parsed from JSON
|
|
442
|
+
"""
|
|
443
|
+
# Arrange
|
|
444
|
+
ps = phase_state_with_existing_file
|
|
445
|
+
|
|
446
|
+
# Act
|
|
447
|
+
state = ps.read("STORY-001")
|
|
448
|
+
|
|
449
|
+
# Assert
|
|
450
|
+
assert state is not None
|
|
451
|
+
assert isinstance(state, dict)
|
|
452
|
+
assert "story_id" in state
|
|
453
|
+
assert "current_phase" in state
|
|
454
|
+
assert "phases" in state
|
|
455
|
+
|
|
456
|
+
def test_read_returns_correct_story_id(self, phase_state_with_existing_file):
|
|
457
|
+
"""
|
|
458
|
+
Given: A state file exists
|
|
459
|
+
When: read() is called
|
|
460
|
+
Then: The returned state has correct story_id
|
|
461
|
+
"""
|
|
462
|
+
# Arrange
|
|
463
|
+
ps = phase_state_with_existing_file
|
|
464
|
+
|
|
465
|
+
# Act
|
|
466
|
+
state = ps.read("STORY-001")
|
|
467
|
+
|
|
468
|
+
# Assert
|
|
469
|
+
assert state["story_id"] == "STORY-001"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# =============================================================================
|
|
473
|
+
# AC#5: Read returns None for non-existent state
|
|
474
|
+
# =============================================================================
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class TestAC5ReadNonExistentState:
|
|
478
|
+
"""Tests for AC#5: Read returns None for non-existent state."""
|
|
479
|
+
|
|
480
|
+
def test_read_non_existent_returns_none(self, phase_state):
|
|
481
|
+
"""
|
|
482
|
+
Given: No state file exists for STORY-999
|
|
483
|
+
When: read(story_id="STORY-999") is called
|
|
484
|
+
Then: The method returns None (not an exception)
|
|
485
|
+
"""
|
|
486
|
+
# Arrange & Act
|
|
487
|
+
result = phase_state.read("STORY-999")
|
|
488
|
+
|
|
489
|
+
# Assert
|
|
490
|
+
assert result is None
|
|
491
|
+
|
|
492
|
+
def test_read_non_existent_does_not_raise_exception(self, phase_state):
|
|
493
|
+
"""
|
|
494
|
+
Given: No state file exists
|
|
495
|
+
When: read() is called for non-existent story
|
|
496
|
+
Then: No exception is raised
|
|
497
|
+
"""
|
|
498
|
+
# Arrange & Act & Assert
|
|
499
|
+
try:
|
|
500
|
+
phase_state.read("STORY-999")
|
|
501
|
+
except Exception:
|
|
502
|
+
pytest.fail("read() should not raise exception for non-existent file")
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# =============================================================================
|
|
506
|
+
# AC#6: Complete phase with sequential enforcement
|
|
507
|
+
# =============================================================================
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class TestAC6CompletePhase:
|
|
511
|
+
"""Tests for AC#6: Complete phase with sequential enforcement.
|
|
512
|
+
|
|
513
|
+
STORY-307: Updated to call record_subagent() before complete_phase()
|
|
514
|
+
to satisfy STORY-306 subagent enforcement requirements.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
def test_complete_phase_updates_status_to_completed(
|
|
518
|
+
self, phase_state_with_existing_file
|
|
519
|
+
):
|
|
520
|
+
"""
|
|
521
|
+
Given: A state file exists with current_phase="01"
|
|
522
|
+
When: complete_phase(story_id, phase="01", checkpoint_passed=True) is called
|
|
523
|
+
Then: Phase "01" status becomes "completed"
|
|
524
|
+
"""
|
|
525
|
+
# Arrange
|
|
526
|
+
ps = phase_state_with_existing_file
|
|
527
|
+
|
|
528
|
+
# STORY-307: Record required subagents before completing phase (AC2)
|
|
529
|
+
ps.record_subagent("STORY-001", "01", "git-validator")
|
|
530
|
+
ps.record_subagent("STORY-001", "01", "tech-stack-detector")
|
|
531
|
+
|
|
532
|
+
# Act
|
|
533
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
534
|
+
state = ps.read("STORY-001")
|
|
535
|
+
|
|
536
|
+
# Assert
|
|
537
|
+
assert state["phases"]["01"]["status"] == "completed"
|
|
538
|
+
|
|
539
|
+
def test_complete_phase_records_completed_at_timestamp(
|
|
540
|
+
self, phase_state_with_existing_file
|
|
541
|
+
):
|
|
542
|
+
"""
|
|
543
|
+
Given: A phase is completed
|
|
544
|
+
When: complete_phase() is called
|
|
545
|
+
Then: completed_at timestamp is recorded
|
|
546
|
+
"""
|
|
547
|
+
# Arrange
|
|
548
|
+
ps = phase_state_with_existing_file
|
|
549
|
+
|
|
550
|
+
# STORY-307: Record required subagents before completing phase (AC2)
|
|
551
|
+
ps.record_subagent("STORY-001", "01", "git-validator")
|
|
552
|
+
ps.record_subagent("STORY-001", "01", "tech-stack-detector")
|
|
553
|
+
|
|
554
|
+
# Act
|
|
555
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
556
|
+
state = ps.read("STORY-001")
|
|
557
|
+
|
|
558
|
+
# Assert
|
|
559
|
+
assert "completed_at" in state["phases"]["01"]
|
|
560
|
+
assert state["phases"]["01"]["completed_at"].endswith("Z")
|
|
561
|
+
|
|
562
|
+
def test_complete_phase_stores_checkpoint_passed(
|
|
563
|
+
self, phase_state_with_existing_file
|
|
564
|
+
):
|
|
565
|
+
"""
|
|
566
|
+
Given: A phase is completed
|
|
567
|
+
When: complete_phase(checkpoint_passed=True) is called
|
|
568
|
+
Then: checkpoint_passed is stored
|
|
569
|
+
"""
|
|
570
|
+
# Arrange
|
|
571
|
+
ps = phase_state_with_existing_file
|
|
572
|
+
|
|
573
|
+
# STORY-307: Record required subagents before completing phase (AC2)
|
|
574
|
+
ps.record_subagent("STORY-001", "01", "git-validator")
|
|
575
|
+
ps.record_subagent("STORY-001", "01", "tech-stack-detector")
|
|
576
|
+
|
|
577
|
+
# Act
|
|
578
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
579
|
+
state = ps.read("STORY-001")
|
|
580
|
+
|
|
581
|
+
# Assert
|
|
582
|
+
assert state["phases"]["01"]["checkpoint_passed"] is True
|
|
583
|
+
|
|
584
|
+
def test_complete_phase_advances_current_phase(
|
|
585
|
+
self, phase_state_with_existing_file
|
|
586
|
+
):
|
|
587
|
+
"""
|
|
588
|
+
Given: Phase "01" is the current phase
|
|
589
|
+
When: complete_phase("01") is called
|
|
590
|
+
Then: current_phase advances to "02"
|
|
591
|
+
"""
|
|
592
|
+
# Arrange
|
|
593
|
+
ps = phase_state_with_existing_file
|
|
594
|
+
|
|
595
|
+
# STORY-307: Record required subagents before completing phase (AC2)
|
|
596
|
+
ps.record_subagent("STORY-001", "01", "git-validator")
|
|
597
|
+
ps.record_subagent("STORY-001", "01", "tech-stack-detector")
|
|
598
|
+
|
|
599
|
+
# Act
|
|
600
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
601
|
+
state = ps.read("STORY-001")
|
|
602
|
+
|
|
603
|
+
# Assert
|
|
604
|
+
assert state["current_phase"] == "02"
|
|
605
|
+
|
|
606
|
+
def test_complete_phase_10_stays_at_10(self, phase_state_with_existing_file):
|
|
607
|
+
"""
|
|
608
|
+
Given: Phase "10" is the current phase
|
|
609
|
+
When: complete_phase("10") is called
|
|
610
|
+
Then: current_phase stays at "10" (no phase 11)
|
|
611
|
+
"""
|
|
612
|
+
# Arrange
|
|
613
|
+
ps = phase_state_with_existing_file
|
|
614
|
+
|
|
615
|
+
# Complete phases 01-09 first
|
|
616
|
+
state = ps.read("STORY-001")
|
|
617
|
+
for i in range(1, 10):
|
|
618
|
+
phase_id = f"{i:02d}"
|
|
619
|
+
state["current_phase"] = phase_id
|
|
620
|
+
state["phases"][phase_id]["status"] = "pending"
|
|
621
|
+
# Set to phase 10
|
|
622
|
+
state["current_phase"] = "10"
|
|
623
|
+
|
|
624
|
+
# Write modified state directly
|
|
625
|
+
state_path = ps._get_state_path("STORY-001")
|
|
626
|
+
state_path.write_text(json.dumps(state, indent=2))
|
|
627
|
+
|
|
628
|
+
# STORY-307: Record required subagent before completing phase (AC2)
|
|
629
|
+
ps.record_subagent("STORY-001", "10", "dev-result-interpreter")
|
|
630
|
+
|
|
631
|
+
# Act
|
|
632
|
+
ps.complete_phase("STORY-001", "10", checkpoint_passed=True)
|
|
633
|
+
final_state = ps.read("STORY-001")
|
|
634
|
+
|
|
635
|
+
# Assert
|
|
636
|
+
assert final_state["current_phase"] == "10"
|
|
637
|
+
assert final_state["phases"]["10"]["status"] == "completed"
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
# =============================================================================
|
|
641
|
+
# AC#7: Phase transition validation (sequential order only)
|
|
642
|
+
# =============================================================================
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
class TestAC7PhaseTransitionValidation:
|
|
646
|
+
"""Tests for AC#7: Phase transition validation."""
|
|
647
|
+
|
|
648
|
+
def test_complete_phase_skip_raises_phase_transition_error(
|
|
649
|
+
self, phase_state_with_existing_file
|
|
650
|
+
):
|
|
651
|
+
"""
|
|
652
|
+
Given: A state file exists with current_phase="02"
|
|
653
|
+
When: complete_phase(phase="05") is called (attempting to skip)
|
|
654
|
+
Then: A PhaseTransitionError is raised
|
|
655
|
+
"""
|
|
656
|
+
from devforgeai_cli.phase_state import PhaseTransitionError
|
|
657
|
+
|
|
658
|
+
# Arrange
|
|
659
|
+
ps = phase_state_with_existing_file
|
|
660
|
+
|
|
661
|
+
# Set current_phase to "02"
|
|
662
|
+
state = ps.read("STORY-001")
|
|
663
|
+
state["current_phase"] = "02"
|
|
664
|
+
state["phases"]["01"]["status"] = "completed"
|
|
665
|
+
state_path = ps._get_state_path("STORY-001")
|
|
666
|
+
state_path.write_text(json.dumps(state, indent=2))
|
|
667
|
+
|
|
668
|
+
# Act & Assert
|
|
669
|
+
with pytest.raises(PhaseTransitionError) as exc_info:
|
|
670
|
+
ps.complete_phase("STORY-001", "05", checkpoint_passed=True)
|
|
671
|
+
|
|
672
|
+
assert "sequential" in str(exc_info.value).lower()
|
|
673
|
+
|
|
674
|
+
def test_complete_phase_previous_phase_raises_error(
|
|
675
|
+
self, phase_state_with_existing_file
|
|
676
|
+
):
|
|
677
|
+
"""
|
|
678
|
+
Given: current_phase is "03"
|
|
679
|
+
When: complete_phase(phase="01") is called (previous phase)
|
|
680
|
+
Then: A PhaseTransitionError is raised
|
|
681
|
+
"""
|
|
682
|
+
from devforgeai_cli.phase_state import PhaseTransitionError
|
|
683
|
+
|
|
684
|
+
# Arrange
|
|
685
|
+
ps = phase_state_with_existing_file
|
|
686
|
+
|
|
687
|
+
# Set current_phase to "03"
|
|
688
|
+
state = ps.read("STORY-001")
|
|
689
|
+
state["current_phase"] = "03"
|
|
690
|
+
state_path = ps._get_state_path("STORY-001")
|
|
691
|
+
state_path.write_text(json.dumps(state, indent=2))
|
|
692
|
+
|
|
693
|
+
# Act & Assert
|
|
694
|
+
with pytest.raises(PhaseTransitionError):
|
|
695
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
696
|
+
|
|
697
|
+
def test_complete_phase_error_message_indicates_sequential(
|
|
698
|
+
self, phase_state_with_existing_file
|
|
699
|
+
):
|
|
700
|
+
"""
|
|
701
|
+
Given: An attempt to skip phases
|
|
702
|
+
When: PhaseTransitionError is raised
|
|
703
|
+
Then: Error message indicates sequential completion required
|
|
704
|
+
"""
|
|
705
|
+
from devforgeai_cli.phase_state import PhaseTransitionError
|
|
706
|
+
|
|
707
|
+
# Arrange
|
|
708
|
+
ps = phase_state_with_existing_file
|
|
709
|
+
|
|
710
|
+
# Act & Assert
|
|
711
|
+
with pytest.raises(PhaseTransitionError) as exc_info:
|
|
712
|
+
ps.complete_phase("STORY-001", "05", checkpoint_passed=True)
|
|
713
|
+
|
|
714
|
+
error_message = str(exc_info.value)
|
|
715
|
+
assert "sequential" in error_message.lower() or "order" in error_message.lower()
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# =============================================================================
|
|
719
|
+
# AC#8: Record subagent invocation
|
|
720
|
+
# =============================================================================
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
class TestAC8RecordSubagent:
|
|
724
|
+
"""Tests for AC#8: Record subagent invocation."""
|
|
725
|
+
|
|
726
|
+
def test_record_subagent_appends_to_invoked_list(
|
|
727
|
+
self, phase_state_with_existing_file
|
|
728
|
+
):
|
|
729
|
+
"""
|
|
730
|
+
Given: A state file exists for STORY-001
|
|
731
|
+
When: record_subagent(story_id, phase="02", subagent="test-automator") is called
|
|
732
|
+
Then: "test-automator" is appended to phases["02"]["subagents_invoked"]
|
|
733
|
+
"""
|
|
734
|
+
# Arrange
|
|
735
|
+
ps = phase_state_with_existing_file
|
|
736
|
+
|
|
737
|
+
# Act
|
|
738
|
+
ps.record_subagent("STORY-001", "02", "test-automator")
|
|
739
|
+
state = ps.read("STORY-001")
|
|
740
|
+
|
|
741
|
+
# Assert
|
|
742
|
+
assert "test-automator" in state["phases"]["02"]["subagents_invoked"]
|
|
743
|
+
|
|
744
|
+
def test_record_subagent_sets_started_at_timestamp(
|
|
745
|
+
self, phase_state_with_existing_file
|
|
746
|
+
):
|
|
747
|
+
"""
|
|
748
|
+
Given: A phase without started_at
|
|
749
|
+
When: record_subagent() is called
|
|
750
|
+
Then: started_at timestamp is recorded
|
|
751
|
+
"""
|
|
752
|
+
# Arrange
|
|
753
|
+
ps = phase_state_with_existing_file
|
|
754
|
+
|
|
755
|
+
# Act
|
|
756
|
+
ps.record_subagent("STORY-001", "02", "test-automator")
|
|
757
|
+
state = ps.read("STORY-001")
|
|
758
|
+
|
|
759
|
+
# Assert
|
|
760
|
+
assert "started_at" in state["phases"]["02"]
|
|
761
|
+
|
|
762
|
+
def test_record_subagent_idempotent_no_duplicates(
|
|
763
|
+
self, phase_state_with_existing_file
|
|
764
|
+
):
|
|
765
|
+
"""
|
|
766
|
+
Given: Subagent already recorded for a phase
|
|
767
|
+
When: record_subagent() is called again with same subagent
|
|
768
|
+
Then: Subagent is not duplicated (idempotent)
|
|
769
|
+
"""
|
|
770
|
+
# Arrange
|
|
771
|
+
ps = phase_state_with_existing_file
|
|
772
|
+
|
|
773
|
+
# Act
|
|
774
|
+
ps.record_subagent("STORY-001", "02", "test-automator")
|
|
775
|
+
ps.record_subagent("STORY-001", "02", "test-automator")
|
|
776
|
+
state = ps.read("STORY-001")
|
|
777
|
+
|
|
778
|
+
# Assert
|
|
779
|
+
count = state["phases"]["02"]["subagents_invoked"].count("test-automator")
|
|
780
|
+
assert count == 1
|
|
781
|
+
|
|
782
|
+
def test_record_subagent_returns_updated_state(
|
|
783
|
+
self, phase_state_with_existing_file
|
|
784
|
+
):
|
|
785
|
+
"""
|
|
786
|
+
Given: A valid state file
|
|
787
|
+
When: record_subagent() is called
|
|
788
|
+
Then: Returns updated state dictionary
|
|
789
|
+
"""
|
|
790
|
+
# Arrange
|
|
791
|
+
ps = phase_state_with_existing_file
|
|
792
|
+
|
|
793
|
+
# Act
|
|
794
|
+
result = ps.record_subagent("STORY-001", "02", "test-automator")
|
|
795
|
+
|
|
796
|
+
# Assert
|
|
797
|
+
assert isinstance(result, dict)
|
|
798
|
+
assert "test-automator" in result["phases"]["02"]["subagents_invoked"]
|
|
799
|
+
|
|
800
|
+
def test_record_subagent_raises_for_nonexistent_state(self, phase_state):
|
|
801
|
+
"""
|
|
802
|
+
Given: No state file exists
|
|
803
|
+
When: record_subagent() is called
|
|
804
|
+
Then: Raises FileNotFoundError
|
|
805
|
+
"""
|
|
806
|
+
# Arrange & Act & Assert
|
|
807
|
+
with pytest.raises(FileNotFoundError):
|
|
808
|
+
phase_state.record_subagent("STORY-999", "02", "test-automator")
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
# =============================================================================
|
|
812
|
+
# AC#9: Add workflow observation
|
|
813
|
+
# =============================================================================
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
class TestAC9AddObservation:
|
|
817
|
+
"""Tests for AC#9: Add workflow observation."""
|
|
818
|
+
|
|
819
|
+
def test_add_observation_appends_to_observations_array(
|
|
820
|
+
self, phase_state_with_existing_file
|
|
821
|
+
):
|
|
822
|
+
"""
|
|
823
|
+
Given: A state file exists for STORY-001
|
|
824
|
+
When: add_observation() is called
|
|
825
|
+
Then: An observation object is appended to the observations array
|
|
826
|
+
"""
|
|
827
|
+
# Arrange
|
|
828
|
+
ps = phase_state_with_existing_file
|
|
829
|
+
|
|
830
|
+
# Act
|
|
831
|
+
ps.add_observation(
|
|
832
|
+
story_id="STORY-001",
|
|
833
|
+
phase_id="04",
|
|
834
|
+
category="friction",
|
|
835
|
+
note="Test took longer than expected",
|
|
836
|
+
severity="medium"
|
|
837
|
+
)
|
|
838
|
+
state = ps.read("STORY-001")
|
|
839
|
+
|
|
840
|
+
# Assert
|
|
841
|
+
assert len(state["observations"]) == 1
|
|
842
|
+
|
|
843
|
+
def test_add_observation_returns_unique_id_format(
|
|
844
|
+
self, phase_state_with_existing_file
|
|
845
|
+
):
|
|
846
|
+
"""
|
|
847
|
+
Given: An observation is added
|
|
848
|
+
When: add_observation() returns
|
|
849
|
+
Then: Returns ID matching format obs-{phase_id}-{8-char-uuid}
|
|
850
|
+
"""
|
|
851
|
+
# Arrange
|
|
852
|
+
ps = phase_state_with_existing_file
|
|
853
|
+
|
|
854
|
+
# Act
|
|
855
|
+
observation_id = ps.add_observation(
|
|
856
|
+
story_id="STORY-001",
|
|
857
|
+
phase_id="04",
|
|
858
|
+
category="friction",
|
|
859
|
+
note="Test observation",
|
|
860
|
+
severity="medium"
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
# Assert
|
|
864
|
+
assert observation_id is not None
|
|
865
|
+
assert observation_id.startswith("obs-04-")
|
|
866
|
+
# 8-char uuid after the prefix
|
|
867
|
+
parts = observation_id.split("-")
|
|
868
|
+
assert len(parts) == 3
|
|
869
|
+
assert len(parts[2]) == 8
|
|
870
|
+
|
|
871
|
+
def test_add_observation_stores_all_fields(
|
|
872
|
+
self, phase_state_with_existing_file
|
|
873
|
+
):
|
|
874
|
+
"""
|
|
875
|
+
Given: An observation is added
|
|
876
|
+
When: add_observation() is called with all parameters
|
|
877
|
+
Then: All fields are stored correctly
|
|
878
|
+
"""
|
|
879
|
+
# Arrange
|
|
880
|
+
ps = phase_state_with_existing_file
|
|
881
|
+
|
|
882
|
+
# Act
|
|
883
|
+
ps.add_observation(
|
|
884
|
+
story_id="STORY-001",
|
|
885
|
+
phase_id="04",
|
|
886
|
+
category="friction",
|
|
887
|
+
note="Test took longer than expected",
|
|
888
|
+
severity="medium"
|
|
889
|
+
)
|
|
890
|
+
state = ps.read("STORY-001")
|
|
891
|
+
|
|
892
|
+
# Assert
|
|
893
|
+
obs = state["observations"][0]
|
|
894
|
+
assert "id" in obs
|
|
895
|
+
assert obs["phase"] == "04"
|
|
896
|
+
assert obs["category"] == "friction"
|
|
897
|
+
assert obs["note"] == "Test took longer than expected"
|
|
898
|
+
assert obs["severity"] == "medium"
|
|
899
|
+
assert "timestamp" in obs
|
|
900
|
+
|
|
901
|
+
def test_add_observation_records_timestamp(
|
|
902
|
+
self, phase_state_with_existing_file
|
|
903
|
+
):
|
|
904
|
+
"""
|
|
905
|
+
Given: An observation is added
|
|
906
|
+
When: add_observation() is called
|
|
907
|
+
Then: Timestamp is recorded in ISO-8601 format
|
|
908
|
+
"""
|
|
909
|
+
# Arrange
|
|
910
|
+
ps = phase_state_with_existing_file
|
|
911
|
+
|
|
912
|
+
# Act
|
|
913
|
+
ps.add_observation(
|
|
914
|
+
story_id="STORY-001",
|
|
915
|
+
phase_id="04",
|
|
916
|
+
category="friction",
|
|
917
|
+
note="Test observation",
|
|
918
|
+
severity="medium"
|
|
919
|
+
)
|
|
920
|
+
state = ps.read("STORY-001")
|
|
921
|
+
|
|
922
|
+
# Assert
|
|
923
|
+
timestamp = state["observations"][0]["timestamp"]
|
|
924
|
+
assert timestamp.endswith("Z")
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
# =============================================================================
|
|
928
|
+
# AC#10: Input validation for story ID format
|
|
929
|
+
# =============================================================================
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
class TestAC10StoryIdValidation:
|
|
933
|
+
"""Tests for AC#10: Input validation for story ID format."""
|
|
934
|
+
|
|
935
|
+
def test_create_invalid_story_id_raises_value_error(self, phase_state):
|
|
936
|
+
"""
|
|
937
|
+
Given: An invalid story ID "INVALID-ID"
|
|
938
|
+
When: create() is called with this story ID
|
|
939
|
+
Then: A ValueError is raised
|
|
940
|
+
"""
|
|
941
|
+
# Arrange & Act & Assert
|
|
942
|
+
with pytest.raises(ValueError):
|
|
943
|
+
phase_state.create("INVALID-ID")
|
|
944
|
+
|
|
945
|
+
def test_create_invalid_story_id_error_message(self, phase_state):
|
|
946
|
+
"""
|
|
947
|
+
Given: An invalid story ID
|
|
948
|
+
When: ValueError is raised
|
|
949
|
+
Then: Message contains "Invalid story_id" and pattern example
|
|
950
|
+
"""
|
|
951
|
+
# Arrange & Act & Assert
|
|
952
|
+
with pytest.raises(ValueError) as exc_info:
|
|
953
|
+
phase_state.create("INVALID-ID")
|
|
954
|
+
|
|
955
|
+
error_message = str(exc_info.value)
|
|
956
|
+
assert "Invalid story_id" in error_message
|
|
957
|
+
assert "STORY-XXX" in error_message or "STORY-001" in error_message
|
|
958
|
+
|
|
959
|
+
def test_read_invalid_story_id_raises_value_error(self, phase_state):
|
|
960
|
+
"""
|
|
961
|
+
Given: An invalid story ID
|
|
962
|
+
When: read() is called
|
|
963
|
+
Then: ValueError is raised (validation before file check)
|
|
964
|
+
"""
|
|
965
|
+
# Note: Implementation may validate before checking file existence
|
|
966
|
+
# This test assumes validation happens first
|
|
967
|
+
# If not, read should return None without validation
|
|
968
|
+
pass # Implementation decision: may or may not validate on read
|
|
969
|
+
|
|
970
|
+
@pytest.mark.parametrize("invalid_id", [
|
|
971
|
+
"STORY-1", # Too few digits
|
|
972
|
+
"STORY-01", # Two digits (spec says 3)
|
|
973
|
+
"STORY-0001", # Too many digits
|
|
974
|
+
"story-001", # Lowercase
|
|
975
|
+
"STORY_001", # Underscore instead of dash
|
|
976
|
+
"STORY001", # No dash
|
|
977
|
+
"001-STORY", # Wrong order
|
|
978
|
+
"../STORY-001", # Path traversal attempt
|
|
979
|
+
"STORY-001/../", # Path traversal attempt
|
|
980
|
+
])
|
|
981
|
+
def test_invalid_story_id_patterns_rejected(self, phase_state, invalid_id: str):
|
|
982
|
+
"""
|
|
983
|
+
Given: Various invalid story ID patterns
|
|
984
|
+
When: create() is called
|
|
985
|
+
Then: ValueError is raised for all invalid patterns
|
|
986
|
+
"""
|
|
987
|
+
# Arrange & Act & Assert
|
|
988
|
+
with pytest.raises(ValueError):
|
|
989
|
+
phase_state.create(invalid_id)
|
|
990
|
+
|
|
991
|
+
def test_path_traversal_story_id_rejected(self, phase_state):
|
|
992
|
+
"""
|
|
993
|
+
Given: A story ID with path traversal attempt
|
|
994
|
+
When: Any method is called
|
|
995
|
+
Then: ValueError is raised (security)
|
|
996
|
+
"""
|
|
997
|
+
# Arrange & Act & Assert
|
|
998
|
+
with pytest.raises(ValueError):
|
|
999
|
+
phase_state.create("../etc/passwd")
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
# =============================================================================
|
|
1003
|
+
# AC#11: Input validation for phase ID
|
|
1004
|
+
# =============================================================================
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
class TestAC11PhaseIdValidation:
|
|
1008
|
+
"""Tests for AC#11: Input validation for phase ID."""
|
|
1009
|
+
|
|
1010
|
+
def test_complete_phase_invalid_phase_raises_phase_not_found(
|
|
1011
|
+
self, phase_state_with_existing_file
|
|
1012
|
+
):
|
|
1013
|
+
"""
|
|
1014
|
+
Given: An invalid phase ID "15"
|
|
1015
|
+
When: complete_phase() is called with this phase ID
|
|
1016
|
+
Then: A PhaseNotFoundError is raised
|
|
1017
|
+
"""
|
|
1018
|
+
from devforgeai_cli.phase_state import PhaseNotFoundError
|
|
1019
|
+
|
|
1020
|
+
# Arrange
|
|
1021
|
+
ps = phase_state_with_existing_file
|
|
1022
|
+
|
|
1023
|
+
# Act & Assert
|
|
1024
|
+
with pytest.raises(PhaseNotFoundError):
|
|
1025
|
+
ps.complete_phase("STORY-001", "15", checkpoint_passed=True)
|
|
1026
|
+
|
|
1027
|
+
def test_record_subagent_invalid_phase_raises_phase_not_found(
|
|
1028
|
+
self, phase_state_with_existing_file
|
|
1029
|
+
):
|
|
1030
|
+
"""
|
|
1031
|
+
Given: An invalid phase ID
|
|
1032
|
+
When: record_subagent() is called
|
|
1033
|
+
Then: PhaseNotFoundError is raised
|
|
1034
|
+
"""
|
|
1035
|
+
from devforgeai_cli.phase_state import PhaseNotFoundError
|
|
1036
|
+
|
|
1037
|
+
# Arrange
|
|
1038
|
+
ps = phase_state_with_existing_file
|
|
1039
|
+
|
|
1040
|
+
# Act & Assert
|
|
1041
|
+
with pytest.raises(PhaseNotFoundError):
|
|
1042
|
+
ps.record_subagent("STORY-001", "15", "test-automator")
|
|
1043
|
+
|
|
1044
|
+
def test_phase_not_found_error_message_shows_valid_phases(
|
|
1045
|
+
self, phase_state_with_existing_file
|
|
1046
|
+
):
|
|
1047
|
+
"""
|
|
1048
|
+
Given: An invalid phase ID
|
|
1049
|
+
When: PhaseNotFoundError is raised
|
|
1050
|
+
Then: Error message indicates valid phases are "01" through "10"
|
|
1051
|
+
"""
|
|
1052
|
+
from devforgeai_cli.phase_state import PhaseNotFoundError
|
|
1053
|
+
|
|
1054
|
+
# Arrange
|
|
1055
|
+
ps = phase_state_with_existing_file
|
|
1056
|
+
|
|
1057
|
+
# Act & Assert
|
|
1058
|
+
with pytest.raises(PhaseNotFoundError) as exc_info:
|
|
1059
|
+
ps.complete_phase("STORY-001", "15", checkpoint_passed=True)
|
|
1060
|
+
|
|
1061
|
+
error_message = str(exc_info.value)
|
|
1062
|
+
assert "01" in error_message or "10" in error_message
|
|
1063
|
+
|
|
1064
|
+
@pytest.mark.parametrize("invalid_phase", [
|
|
1065
|
+
"0", # Below range
|
|
1066
|
+
"00", # Zero phase
|
|
1067
|
+
"11", # Above range
|
|
1068
|
+
"15", # Well above range
|
|
1069
|
+
"1", # Single digit
|
|
1070
|
+
"001", # Three digits
|
|
1071
|
+
"-1", # Negative
|
|
1072
|
+
"one", # Text
|
|
1073
|
+
])
|
|
1074
|
+
def test_invalid_phase_id_patterns_rejected(
|
|
1075
|
+
self, phase_state_with_existing_file, invalid_phase: str
|
|
1076
|
+
):
|
|
1077
|
+
"""
|
|
1078
|
+
Given: Various invalid phase ID patterns
|
|
1079
|
+
When: complete_phase() is called
|
|
1080
|
+
Then: PhaseNotFoundError is raised
|
|
1081
|
+
"""
|
|
1082
|
+
from devforgeai_cli.phase_state import PhaseNotFoundError
|
|
1083
|
+
|
|
1084
|
+
# Arrange
|
|
1085
|
+
ps = phase_state_with_existing_file
|
|
1086
|
+
|
|
1087
|
+
# Act & Assert
|
|
1088
|
+
with pytest.raises(PhaseNotFoundError):
|
|
1089
|
+
ps.complete_phase("STORY-001", invalid_phase, checkpoint_passed=True)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
# =============================================================================
|
|
1093
|
+
# AC#12: State file path helper method
|
|
1094
|
+
# =============================================================================
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
class TestAC12StateFilePath:
|
|
1098
|
+
"""Tests for AC#12: State file path helper method."""
|
|
1099
|
+
|
|
1100
|
+
def test_get_state_path_returns_correct_path(self, temp_project_root: Path):
|
|
1101
|
+
"""
|
|
1102
|
+
Given: A PhaseState instance
|
|
1103
|
+
When: _get_state_path(story_id="STORY-001") is called
|
|
1104
|
+
Then: Returns Path("{project_root}/devforgeai/workflows/STORY-001-phase-state.json")
|
|
1105
|
+
"""
|
|
1106
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
1107
|
+
|
|
1108
|
+
# Arrange
|
|
1109
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
1110
|
+
expected_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
|
|
1111
|
+
|
|
1112
|
+
# Act
|
|
1113
|
+
result = ps._get_state_path("STORY-001")
|
|
1114
|
+
|
|
1115
|
+
# Assert
|
|
1116
|
+
assert result == expected_path
|
|
1117
|
+
|
|
1118
|
+
def test_get_state_path_returns_path_object(self, phase_state):
|
|
1119
|
+
"""
|
|
1120
|
+
Given: A PhaseState instance
|
|
1121
|
+
When: _get_state_path() is called
|
|
1122
|
+
Then: Returns a Path object
|
|
1123
|
+
"""
|
|
1124
|
+
# Arrange & Act
|
|
1125
|
+
result = phase_state._get_state_path("STORY-001")
|
|
1126
|
+
|
|
1127
|
+
# Assert
|
|
1128
|
+
assert isinstance(result, Path)
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
# =============================================================================
|
|
1132
|
+
# Edge Case Tests: Corrupted JSON
|
|
1133
|
+
# =============================================================================
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
class TestEdgeCaseCorruptedJSON:
|
|
1137
|
+
"""Tests for edge case: Corrupted JSON state file."""
|
|
1138
|
+
|
|
1139
|
+
def test_read_corrupted_json_raises_state_file_corruption_error(
|
|
1140
|
+
self, corrupted_state_file
|
|
1141
|
+
):
|
|
1142
|
+
"""
|
|
1143
|
+
Given: A corrupted JSON state file
|
|
1144
|
+
When: read() is called
|
|
1145
|
+
Then: StateFileCorruptionError is raised
|
|
1146
|
+
"""
|
|
1147
|
+
from devforgeai_cli.phase_state import PhaseState, StateFileCorruptionError
|
|
1148
|
+
|
|
1149
|
+
# Arrange
|
|
1150
|
+
ps = PhaseState(project_root=corrupted_state_file)
|
|
1151
|
+
|
|
1152
|
+
# Act & Assert
|
|
1153
|
+
with pytest.raises(StateFileCorruptionError):
|
|
1154
|
+
ps.read("STORY-002")
|
|
1155
|
+
|
|
1156
|
+
def test_corruption_error_includes_recovery_message(
|
|
1157
|
+
self, corrupted_state_file
|
|
1158
|
+
):
|
|
1159
|
+
"""
|
|
1160
|
+
Given: A corrupted JSON state file
|
|
1161
|
+
When: StateFileCorruptionError is raised
|
|
1162
|
+
Then: Error message includes recovery instructions
|
|
1163
|
+
"""
|
|
1164
|
+
from devforgeai_cli.phase_state import PhaseState, StateFileCorruptionError
|
|
1165
|
+
|
|
1166
|
+
# Arrange
|
|
1167
|
+
ps = PhaseState(project_root=corrupted_state_file)
|
|
1168
|
+
|
|
1169
|
+
# Act & Assert
|
|
1170
|
+
with pytest.raises(StateFileCorruptionError) as exc_info:
|
|
1171
|
+
ps.read("STORY-002")
|
|
1172
|
+
|
|
1173
|
+
error_message = str(exc_info.value)
|
|
1174
|
+
assert "recovery" in error_message.lower() or "delete" in error_message.lower()
|
|
1175
|
+
|
|
1176
|
+
def test_read_empty_file_raises_state_file_corruption_error(
|
|
1177
|
+
self, empty_state_file
|
|
1178
|
+
):
|
|
1179
|
+
"""
|
|
1180
|
+
Given: An empty state file
|
|
1181
|
+
When: read() is called
|
|
1182
|
+
Then: StateFileCorruptionError is raised (treat as corrupted)
|
|
1183
|
+
"""
|
|
1184
|
+
from devforgeai_cli.phase_state import PhaseState, StateFileCorruptionError
|
|
1185
|
+
|
|
1186
|
+
# Arrange
|
|
1187
|
+
ps = PhaseState(project_root=empty_state_file)
|
|
1188
|
+
|
|
1189
|
+
# Act & Assert
|
|
1190
|
+
with pytest.raises(StateFileCorruptionError):
|
|
1191
|
+
ps.read("STORY-003")
|
|
1192
|
+
|
|
1193
|
+
|
|
1194
|
+
# =============================================================================
|
|
1195
|
+
# Edge Case Tests: Observation Validation
|
|
1196
|
+
# =============================================================================
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
class TestEdgeCaseObservationValidation:
|
|
1200
|
+
"""Tests for edge cases: Observation input validation."""
|
|
1201
|
+
|
|
1202
|
+
def test_add_observation_empty_note_raises_value_error(
|
|
1203
|
+
self, phase_state_with_existing_file
|
|
1204
|
+
):
|
|
1205
|
+
"""
|
|
1206
|
+
Given: An empty observation note
|
|
1207
|
+
When: add_observation() is called
|
|
1208
|
+
Then: ValueError is raised
|
|
1209
|
+
"""
|
|
1210
|
+
# Arrange
|
|
1211
|
+
ps = phase_state_with_existing_file
|
|
1212
|
+
|
|
1213
|
+
# Act & Assert
|
|
1214
|
+
with pytest.raises(ValueError) as exc_info:
|
|
1215
|
+
ps.add_observation(
|
|
1216
|
+
story_id="STORY-001",
|
|
1217
|
+
phase_id="04",
|
|
1218
|
+
category="friction",
|
|
1219
|
+
note="",
|
|
1220
|
+
severity="medium"
|
|
1221
|
+
)
|
|
1222
|
+
|
|
1223
|
+
assert "empty" in str(exc_info.value).lower()
|
|
1224
|
+
|
|
1225
|
+
def test_add_observation_whitespace_note_raises_value_error(
|
|
1226
|
+
self, phase_state_with_existing_file
|
|
1227
|
+
):
|
|
1228
|
+
"""
|
|
1229
|
+
Given: A whitespace-only observation note
|
|
1230
|
+
When: add_observation() is called
|
|
1231
|
+
Then: ValueError is raised
|
|
1232
|
+
"""
|
|
1233
|
+
# Arrange
|
|
1234
|
+
ps = phase_state_with_existing_file
|
|
1235
|
+
|
|
1236
|
+
# Act & Assert
|
|
1237
|
+
with pytest.raises(ValueError):
|
|
1238
|
+
ps.add_observation(
|
|
1239
|
+
story_id="STORY-001",
|
|
1240
|
+
phase_id="04",
|
|
1241
|
+
category="friction",
|
|
1242
|
+
note=" ",
|
|
1243
|
+
severity="medium"
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
def test_add_observation_invalid_category_raises_value_error(
|
|
1247
|
+
self, phase_state_with_existing_file
|
|
1248
|
+
):
|
|
1249
|
+
"""
|
|
1250
|
+
Given: An invalid observation category
|
|
1251
|
+
When: add_observation() is called
|
|
1252
|
+
Then: ValueError is raised with valid options
|
|
1253
|
+
"""
|
|
1254
|
+
# Arrange
|
|
1255
|
+
ps = phase_state_with_existing_file
|
|
1256
|
+
|
|
1257
|
+
# Act & Assert
|
|
1258
|
+
with pytest.raises(ValueError) as exc_info:
|
|
1259
|
+
ps.add_observation(
|
|
1260
|
+
story_id="STORY-001",
|
|
1261
|
+
phase_id="04",
|
|
1262
|
+
category="invalid_category",
|
|
1263
|
+
note="Test observation",
|
|
1264
|
+
severity="medium"
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
error_message = str(exc_info.value)
|
|
1268
|
+
# Should mention valid categories
|
|
1269
|
+
assert "friction" in error_message or "gap" in error_message
|
|
1270
|
+
|
|
1271
|
+
def test_add_observation_invalid_severity_raises_value_error(
|
|
1272
|
+
self, phase_state_with_existing_file
|
|
1273
|
+
):
|
|
1274
|
+
"""
|
|
1275
|
+
Given: An invalid observation severity
|
|
1276
|
+
When: add_observation() is called
|
|
1277
|
+
Then: ValueError is raised with valid options
|
|
1278
|
+
"""
|
|
1279
|
+
# Arrange
|
|
1280
|
+
ps = phase_state_with_existing_file
|
|
1281
|
+
|
|
1282
|
+
# Act & Assert
|
|
1283
|
+
with pytest.raises(ValueError) as exc_info:
|
|
1284
|
+
ps.add_observation(
|
|
1285
|
+
story_id="STORY-001",
|
|
1286
|
+
phase_id="04",
|
|
1287
|
+
category="friction",
|
|
1288
|
+
note="Test observation",
|
|
1289
|
+
severity="critical" # Invalid
|
|
1290
|
+
)
|
|
1291
|
+
|
|
1292
|
+
error_message = str(exc_info.value)
|
|
1293
|
+
# Should mention valid severities
|
|
1294
|
+
assert "low" in error_message or "medium" in error_message or "high" in error_message
|
|
1295
|
+
|
|
1296
|
+
@pytest.mark.parametrize("valid_category", [
|
|
1297
|
+
"friction", "gap", "success", "pattern"
|
|
1298
|
+
])
|
|
1299
|
+
def test_add_observation_valid_categories_accepted(
|
|
1300
|
+
self, phase_state_with_existing_file, valid_category: str
|
|
1301
|
+
):
|
|
1302
|
+
"""
|
|
1303
|
+
Given: A valid observation category
|
|
1304
|
+
When: add_observation() is called
|
|
1305
|
+
Then: No error is raised
|
|
1306
|
+
"""
|
|
1307
|
+
# Arrange
|
|
1308
|
+
ps = phase_state_with_existing_file
|
|
1309
|
+
|
|
1310
|
+
# Act
|
|
1311
|
+
result = ps.add_observation(
|
|
1312
|
+
story_id="STORY-001",
|
|
1313
|
+
phase_id="04",
|
|
1314
|
+
category=valid_category,
|
|
1315
|
+
note="Test observation",
|
|
1316
|
+
severity="medium"
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
# Assert
|
|
1320
|
+
assert result is not None
|
|
1321
|
+
|
|
1322
|
+
@pytest.mark.parametrize("valid_severity", ["low", "medium", "high"])
|
|
1323
|
+
def test_add_observation_valid_severities_accepted(
|
|
1324
|
+
self, phase_state_with_existing_file, valid_severity: str
|
|
1325
|
+
):
|
|
1326
|
+
"""
|
|
1327
|
+
Given: A valid observation severity
|
|
1328
|
+
When: add_observation() is called
|
|
1329
|
+
Then: No error is raised
|
|
1330
|
+
"""
|
|
1331
|
+
# Arrange
|
|
1332
|
+
ps = phase_state_with_existing_file
|
|
1333
|
+
|
|
1334
|
+
# Act
|
|
1335
|
+
result = ps.add_observation(
|
|
1336
|
+
story_id="STORY-001",
|
|
1337
|
+
phase_id="04",
|
|
1338
|
+
category="friction",
|
|
1339
|
+
note="Test observation",
|
|
1340
|
+
severity=valid_severity
|
|
1341
|
+
)
|
|
1342
|
+
|
|
1343
|
+
# Assert
|
|
1344
|
+
assert result is not None
|
|
1345
|
+
|
|
1346
|
+
def test_add_observation_note_max_length_1000(
|
|
1347
|
+
self, phase_state_with_existing_file
|
|
1348
|
+
):
|
|
1349
|
+
"""
|
|
1350
|
+
Given: A note exceeding 1000 characters
|
|
1351
|
+
When: add_observation() is called
|
|
1352
|
+
Then: ValueError is raised (max 1000 chars)
|
|
1353
|
+
"""
|
|
1354
|
+
# Arrange
|
|
1355
|
+
ps = phase_state_with_existing_file
|
|
1356
|
+
long_note = "x" * 1001
|
|
1357
|
+
|
|
1358
|
+
# Act & Assert
|
|
1359
|
+
with pytest.raises(ValueError):
|
|
1360
|
+
ps.add_observation(
|
|
1361
|
+
story_id="STORY-001",
|
|
1362
|
+
phase_id="04",
|
|
1363
|
+
category="friction",
|
|
1364
|
+
note=long_note,
|
|
1365
|
+
severity="medium"
|
|
1366
|
+
)
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
# =============================================================================
|
|
1370
|
+
# Edge Case Tests: Atomic File Writes
|
|
1371
|
+
# =============================================================================
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
class TestEdgeCaseAtomicWrites:
|
|
1375
|
+
"""Tests for edge case: Atomic file writes."""
|
|
1376
|
+
|
|
1377
|
+
def test_create_uses_atomic_write(self, phase_state, temp_project_root: Path):
|
|
1378
|
+
"""
|
|
1379
|
+
Given: A state file being created
|
|
1380
|
+
When: create() is called
|
|
1381
|
+
Then: File is written atomically (temp file + rename)
|
|
1382
|
+
"""
|
|
1383
|
+
# This test verifies the behavior, not implementation
|
|
1384
|
+
# We can verify by checking no partial files exist
|
|
1385
|
+
|
|
1386
|
+
# Arrange
|
|
1387
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
1388
|
+
|
|
1389
|
+
# Act
|
|
1390
|
+
phase_state.create("STORY-001")
|
|
1391
|
+
|
|
1392
|
+
# Assert - no .tmp files left behind
|
|
1393
|
+
tmp_files = list(workflows_dir.glob("*.tmp"))
|
|
1394
|
+
assert len(tmp_files) == 0
|
|
1395
|
+
|
|
1396
|
+
def test_complete_phase_uses_atomic_write(
|
|
1397
|
+
self, phase_state_with_existing_file, temp_project_root: Path
|
|
1398
|
+
):
|
|
1399
|
+
"""
|
|
1400
|
+
Given: A phase being completed
|
|
1401
|
+
When: complete_phase() is called
|
|
1402
|
+
Then: File is written atomically
|
|
1403
|
+
"""
|
|
1404
|
+
# Arrange
|
|
1405
|
+
ps = phase_state_with_existing_file
|
|
1406
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
1407
|
+
|
|
1408
|
+
# STORY-307: Record required subagents before completing phase (AC2)
|
|
1409
|
+
ps.record_subagent("STORY-001", "01", "git-validator")
|
|
1410
|
+
ps.record_subagent("STORY-001", "01", "tech-stack-detector")
|
|
1411
|
+
|
|
1412
|
+
# Act
|
|
1413
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
1414
|
+
|
|
1415
|
+
# Assert - no .tmp files left behind
|
|
1416
|
+
tmp_files = list(workflows_dir.glob("*.tmp"))
|
|
1417
|
+
assert len(tmp_files) == 0
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
# =============================================================================
|
|
1421
|
+
# Edge Case Tests: Platform-Aware File Locking
|
|
1422
|
+
# =============================================================================
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
class TestEdgeCaseFileLocking:
|
|
1426
|
+
"""Tests for edge case: Platform-aware file locking."""
|
|
1427
|
+
|
|
1428
|
+
@pytest.mark.skipif(os.name != 'posix', reason="Unix-only test")
|
|
1429
|
+
def test_unix_uses_fcntl_locking(self, phase_state):
|
|
1430
|
+
"""
|
|
1431
|
+
Given: Unix platform (Linux/macOS)
|
|
1432
|
+
When: File operations occur
|
|
1433
|
+
Then: fcntl.flock() is used for locking
|
|
1434
|
+
"""
|
|
1435
|
+
# This is an implementation detail test
|
|
1436
|
+
# Verifies fcntl is imported on Unix
|
|
1437
|
+
import importlib.util
|
|
1438
|
+
|
|
1439
|
+
spec = importlib.util.find_spec("fcntl")
|
|
1440
|
+
assert spec is not None, "fcntl should be available on Unix"
|
|
1441
|
+
|
|
1442
|
+
@pytest.mark.skipif(os.name != 'nt', reason="Windows-only test")
|
|
1443
|
+
def test_windows_uses_msvcrt_or_fallback(self, phase_state):
|
|
1444
|
+
"""
|
|
1445
|
+
Given: Windows platform
|
|
1446
|
+
When: File operations occur
|
|
1447
|
+
Then: msvcrt.locking() or last-write-wins is used
|
|
1448
|
+
"""
|
|
1449
|
+
# This is an implementation detail test
|
|
1450
|
+
# Verifies appropriate handling on Windows
|
|
1451
|
+
import importlib.util
|
|
1452
|
+
|
|
1453
|
+
# msvcrt should be available on Windows
|
|
1454
|
+
spec = importlib.util.find_spec("msvcrt")
|
|
1455
|
+
assert spec is not None, "msvcrt should be available on Windows"
|
|
1456
|
+
|
|
1457
|
+
def test_lock_timeout_raises_lock_timeout_error(
|
|
1458
|
+
self, phase_state_with_existing_file
|
|
1459
|
+
):
|
|
1460
|
+
"""
|
|
1461
|
+
Given: A file lock held by another process
|
|
1462
|
+
When: Lock acquisition times out (>5 seconds)
|
|
1463
|
+
Then: LockTimeoutError is raised
|
|
1464
|
+
"""
|
|
1465
|
+
from devforgeai_cli.phase_state import LockTimeoutError
|
|
1466
|
+
|
|
1467
|
+
# This test is complex to implement in unit tests
|
|
1468
|
+
# Would require multiprocessing or threading
|
|
1469
|
+
# Marking as placeholder for integration testing
|
|
1470
|
+
pass
|
|
1471
|
+
|
|
1472
|
+
def test_concurrent_writes_dont_corrupt_file(
|
|
1473
|
+
self, phase_state_with_existing_file
|
|
1474
|
+
):
|
|
1475
|
+
"""
|
|
1476
|
+
Given: Multiple concurrent write attempts
|
|
1477
|
+
When: record_subagent() called from multiple threads
|
|
1478
|
+
Then: File is not corrupted
|
|
1479
|
+
"""
|
|
1480
|
+
# Arrange
|
|
1481
|
+
ps = phase_state_with_existing_file
|
|
1482
|
+
errors = []
|
|
1483
|
+
|
|
1484
|
+
def record_subagent(subagent_name: str):
|
|
1485
|
+
try:
|
|
1486
|
+
ps.record_subagent("STORY-001", "02", subagent_name)
|
|
1487
|
+
except Exception as e:
|
|
1488
|
+
errors.append(e)
|
|
1489
|
+
|
|
1490
|
+
# Act
|
|
1491
|
+
threads = []
|
|
1492
|
+
for i in range(10):
|
|
1493
|
+
t = threading.Thread(
|
|
1494
|
+
target=record_subagent,
|
|
1495
|
+
args=(f"subagent-{i}",)
|
|
1496
|
+
)
|
|
1497
|
+
threads.append(t)
|
|
1498
|
+
t.start()
|
|
1499
|
+
|
|
1500
|
+
for t in threads:
|
|
1501
|
+
t.join()
|
|
1502
|
+
|
|
1503
|
+
# Assert
|
|
1504
|
+
assert len(errors) == 0, f"Errors during concurrent writes: {errors}"
|
|
1505
|
+
|
|
1506
|
+
# Verify file is valid JSON
|
|
1507
|
+
state = ps.read("STORY-001")
|
|
1508
|
+
assert state is not None
|
|
1509
|
+
|
|
1510
|
+
|
|
1511
|
+
# =============================================================================
|
|
1512
|
+
# Custom Exception Tests
|
|
1513
|
+
# =============================================================================
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
class TestCustomExceptions:
|
|
1517
|
+
"""Tests for custom exception classes."""
|
|
1518
|
+
|
|
1519
|
+
def test_phase_state_error_is_base_exception(self):
|
|
1520
|
+
"""PhaseStateError should be base for all phase state exceptions."""
|
|
1521
|
+
from devforgeai_cli.phase_state import (
|
|
1522
|
+
PhaseStateError,
|
|
1523
|
+
PhaseNotFoundError,
|
|
1524
|
+
StateFileCorruptionError,
|
|
1525
|
+
PhaseTransitionError,
|
|
1526
|
+
LockTimeoutError
|
|
1527
|
+
)
|
|
1528
|
+
|
|
1529
|
+
assert issubclass(PhaseNotFoundError, PhaseStateError)
|
|
1530
|
+
assert issubclass(StateFileCorruptionError, PhaseStateError)
|
|
1531
|
+
assert issubclass(PhaseTransitionError, PhaseStateError)
|
|
1532
|
+
assert issubclass(LockTimeoutError, PhaseStateError)
|
|
1533
|
+
|
|
1534
|
+
def test_phase_not_found_error_stores_phase_id(self):
|
|
1535
|
+
"""PhaseNotFoundError should store the invalid phase_id."""
|
|
1536
|
+
from devforgeai_cli.phase_state import PhaseNotFoundError
|
|
1537
|
+
|
|
1538
|
+
error = PhaseNotFoundError("15")
|
|
1539
|
+
assert error.phase_id == "15"
|
|
1540
|
+
|
|
1541
|
+
def test_state_file_corruption_error_stores_story_id(self):
|
|
1542
|
+
"""StateFileCorruptionError should store story_id and original error."""
|
|
1543
|
+
from devforgeai_cli.phase_state import StateFileCorruptionError
|
|
1544
|
+
|
|
1545
|
+
error = StateFileCorruptionError("STORY-001", "JSON decode error")
|
|
1546
|
+
assert error.story_id == "STORY-001"
|
|
1547
|
+
assert error.original_error == "JSON decode error"
|
|
1548
|
+
|
|
1549
|
+
def test_phase_transition_error_stores_phases(self):
|
|
1550
|
+
"""PhaseTransitionError should store current and attempted phases."""
|
|
1551
|
+
from devforgeai_cli.phase_state import PhaseTransitionError
|
|
1552
|
+
|
|
1553
|
+
error = PhaseTransitionError("STORY-001", "02", "05")
|
|
1554
|
+
assert error.story_id == "STORY-001"
|
|
1555
|
+
assert error.current_phase == "02"
|
|
1556
|
+
assert error.attempted_phase == "05"
|
|
1557
|
+
|
|
1558
|
+
def test_lock_timeout_error_stores_path_and_timeout(self):
|
|
1559
|
+
"""LockTimeoutError should store file path and timeout."""
|
|
1560
|
+
from devforgeai_cli.phase_state import LockTimeoutError
|
|
1561
|
+
|
|
1562
|
+
error = LockTimeoutError("/path/to/file.json", 5)
|
|
1563
|
+
assert error.file_path == "/path/to/file.json"
|
|
1564
|
+
assert error.timeout == 5
|
|
1565
|
+
|
|
1566
|
+
|
|
1567
|
+
# =============================================================================
|
|
1568
|
+
# Performance Tests (NFR-001, NFR-002)
|
|
1569
|
+
# =============================================================================
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
class TestPerformance:
|
|
1573
|
+
"""Tests for non-functional requirements: Performance."""
|
|
1574
|
+
|
|
1575
|
+
@pytest.mark.slow
|
|
1576
|
+
def test_read_latency_under_10ms(self, phase_state_with_existing_file):
|
|
1577
|
+
"""
|
|
1578
|
+
NFR-001: State file read latency < 10ms per read() operation (p99)
|
|
1579
|
+
"""
|
|
1580
|
+
# Arrange
|
|
1581
|
+
ps = phase_state_with_existing_file
|
|
1582
|
+
|
|
1583
|
+
# Act - perform 100 reads
|
|
1584
|
+
times = []
|
|
1585
|
+
for _ in range(100):
|
|
1586
|
+
start = time.perf_counter()
|
|
1587
|
+
ps.read("STORY-001")
|
|
1588
|
+
end = time.perf_counter()
|
|
1589
|
+
times.append((end - start) * 1000) # Convert to ms
|
|
1590
|
+
|
|
1591
|
+
# Assert - p99 should be < 10ms
|
|
1592
|
+
times.sort()
|
|
1593
|
+
p99_index = int(len(times) * 0.99)
|
|
1594
|
+
p99_time = times[p99_index]
|
|
1595
|
+
|
|
1596
|
+
assert p99_time < 10, f"p99 read latency {p99_time:.2f}ms exceeds 10ms threshold"
|
|
1597
|
+
|
|
1598
|
+
@pytest.mark.slow
|
|
1599
|
+
def test_1000_reads_complete_in_10_seconds(
|
|
1600
|
+
self, phase_state_with_existing_file
|
|
1601
|
+
):
|
|
1602
|
+
"""
|
|
1603
|
+
NFR-001: 1000 consecutive reads complete in < 10 seconds
|
|
1604
|
+
"""
|
|
1605
|
+
# Arrange
|
|
1606
|
+
ps = phase_state_with_existing_file
|
|
1607
|
+
|
|
1608
|
+
# Act
|
|
1609
|
+
start = time.perf_counter()
|
|
1610
|
+
for _ in range(1000):
|
|
1611
|
+
ps.read("STORY-001")
|
|
1612
|
+
end = time.perf_counter()
|
|
1613
|
+
|
|
1614
|
+
# Assert
|
|
1615
|
+
elapsed = end - start
|
|
1616
|
+
assert elapsed < 10, f"1000 reads took {elapsed:.2f}s (expected < 10s)"
|
|
1617
|
+
|
|
1618
|
+
@pytest.mark.slow
|
|
1619
|
+
def test_100_writes_complete_in_5_seconds(self, temp_project_root: Path):
|
|
1620
|
+
"""
|
|
1621
|
+
NFR-002: 100 consecutive writes complete in < 5 seconds
|
|
1622
|
+
"""
|
|
1623
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
1624
|
+
|
|
1625
|
+
# Arrange
|
|
1626
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
1627
|
+
|
|
1628
|
+
# Act
|
|
1629
|
+
start = time.perf_counter()
|
|
1630
|
+
for i in range(100):
|
|
1631
|
+
story_id = f"STORY-{i:03d}"
|
|
1632
|
+
ps.create(story_id)
|
|
1633
|
+
end = time.perf_counter()
|
|
1634
|
+
|
|
1635
|
+
# Assert
|
|
1636
|
+
elapsed = end - start
|
|
1637
|
+
assert elapsed < 5, f"100 writes took {elapsed:.2f}s (expected < 5s)"
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
# =============================================================================
|
|
1641
|
+
# Security Tests (NFR-005)
|
|
1642
|
+
# =============================================================================
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
class TestSecurity:
|
|
1646
|
+
"""Tests for non-functional requirements: Security."""
|
|
1647
|
+
|
|
1648
|
+
@pytest.mark.parametrize("malicious_id", [
|
|
1649
|
+
"../etc/passwd",
|
|
1650
|
+
"STORY-001/../../../etc/passwd",
|
|
1651
|
+
"..\\windows\\system32\\config\\sam",
|
|
1652
|
+
"STORY-001/../../sensitive",
|
|
1653
|
+
"STORY-001\x00.json", # Null byte injection
|
|
1654
|
+
])
|
|
1655
|
+
def test_path_traversal_prevention(self, phase_state, malicious_id: str):
|
|
1656
|
+
"""
|
|
1657
|
+
NFR-005: Path traversal prevention - 0 successful traversal attempts
|
|
1658
|
+
"""
|
|
1659
|
+
# Arrange & Act & Assert
|
|
1660
|
+
with pytest.raises(ValueError):
|
|
1661
|
+
phase_state.create(malicious_id)
|
|
1662
|
+
|
|
1663
|
+
def test_state_file_permissions(self, phase_state, temp_project_root: Path):
|
|
1664
|
+
"""
|
|
1665
|
+
State files should have appropriate permissions (0644)
|
|
1666
|
+
"""
|
|
1667
|
+
# Arrange
|
|
1668
|
+
phase_state.create("STORY-001")
|
|
1669
|
+
state_path = temp_project_root / "devforgeai" / "workflows" / "STORY-001-phase-state.json"
|
|
1670
|
+
|
|
1671
|
+
# Act
|
|
1672
|
+
mode = os.stat(state_path).st_mode & 0o777
|
|
1673
|
+
|
|
1674
|
+
# Assert - should be readable by owner and group, not world-writable
|
|
1675
|
+
# 0o644 = owner rw, group r, other r
|
|
1676
|
+
# Accept 0o664 or 0o644 depending on umask
|
|
1677
|
+
assert mode in [0o644, 0o664, 0o666], f"File permissions {oct(mode)} not secure"
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
# =============================================================================
|
|
1681
|
+
# Scalability Tests (NFR-006)
|
|
1682
|
+
# =============================================================================
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
class TestScalability:
|
|
1686
|
+
"""Tests for non-functional requirements: Scalability."""
|
|
1687
|
+
|
|
1688
|
+
@pytest.mark.slow
|
|
1689
|
+
def test_100_concurrent_story_state_files(self, temp_project_root: Path):
|
|
1690
|
+
"""
|
|
1691
|
+
NFR-006: Support 100+ concurrent story state files
|
|
1692
|
+
"""
|
|
1693
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
1694
|
+
|
|
1695
|
+
# Arrange
|
|
1696
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
1697
|
+
|
|
1698
|
+
# Act - create 100 state files
|
|
1699
|
+
for i in range(100):
|
|
1700
|
+
story_id = f"STORY-{i:03d}"
|
|
1701
|
+
ps.create(story_id)
|
|
1702
|
+
|
|
1703
|
+
# Assert - all files exist and are readable
|
|
1704
|
+
for i in range(100):
|
|
1705
|
+
story_id = f"STORY-{i:03d}"
|
|
1706
|
+
state = ps.read(story_id)
|
|
1707
|
+
assert state is not None
|
|
1708
|
+
assert state["story_id"] == story_id
|
|
1709
|
+
|
|
1710
|
+
|
|
1711
|
+
# =============================================================================
|
|
1712
|
+
# Integration Tests: Full Workflow
|
|
1713
|
+
# =============================================================================
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
class TestIntegrationFullWorkflow:
|
|
1717
|
+
"""Integration tests for complete workflow scenarios.
|
|
1718
|
+
|
|
1719
|
+
STORY-307: Updated to handle all 12 phases (including 4.5, 5.5) and
|
|
1720
|
+
record required subagents from PHASE_REQUIRED_SUBAGENTS constant.
|
|
1721
|
+
"""
|
|
1722
|
+
|
|
1723
|
+
def test_complete_workflow_from_creation_to_phase_10(
|
|
1724
|
+
self, temp_project_root: Path
|
|
1725
|
+
):
|
|
1726
|
+
"""
|
|
1727
|
+
Integration test: Create state, record subagents, complete all phases
|
|
1728
|
+
|
|
1729
|
+
STORY-307: Updated to complete all 12 phases with proper subagent recording.
|
|
1730
|
+
"""
|
|
1731
|
+
from devforgeai_cli.phase_state import PhaseState, PHASE_REQUIRED_SUBAGENTS
|
|
1732
|
+
|
|
1733
|
+
# Arrange
|
|
1734
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
1735
|
+
|
|
1736
|
+
# Valid phases in order (includes decimal phases)
|
|
1737
|
+
valid_phases = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
|
|
1738
|
+
|
|
1739
|
+
# Act
|
|
1740
|
+
# 1. Create state
|
|
1741
|
+
state = ps.create("STORY-001")
|
|
1742
|
+
assert state["current_phase"] == "01"
|
|
1743
|
+
|
|
1744
|
+
# 2. Complete all 12 phases
|
|
1745
|
+
for phase_id in valid_phases:
|
|
1746
|
+
# Record required subagents for this phase (AC2)
|
|
1747
|
+
required = PHASE_REQUIRED_SUBAGENTS.get(phase_id, [])
|
|
1748
|
+
for item in required:
|
|
1749
|
+
if isinstance(item, tuple):
|
|
1750
|
+
# OR logic: record first option (backend-architect for Phase 03)
|
|
1751
|
+
ps.record_subagent("STORY-001", phase_id, item[0])
|
|
1752
|
+
else:
|
|
1753
|
+
ps.record_subagent("STORY-001", phase_id, item)
|
|
1754
|
+
|
|
1755
|
+
# Add an observation
|
|
1756
|
+
ps.add_observation(
|
|
1757
|
+
story_id="STORY-001",
|
|
1758
|
+
phase_id=phase_id,
|
|
1759
|
+
category="success",
|
|
1760
|
+
note=f"Completed phase {phase_id}",
|
|
1761
|
+
severity="low"
|
|
1762
|
+
)
|
|
1763
|
+
|
|
1764
|
+
# Complete the phase
|
|
1765
|
+
ps.complete_phase("STORY-001", phase_id, checkpoint_passed=True)
|
|
1766
|
+
|
|
1767
|
+
# 3. Verify final state
|
|
1768
|
+
final_state = ps.read("STORY-001")
|
|
1769
|
+
|
|
1770
|
+
# Assert
|
|
1771
|
+
assert final_state["current_phase"] == "10"
|
|
1772
|
+
for phase_id in valid_phases:
|
|
1773
|
+
assert final_state["phases"][phase_id]["status"] == "completed"
|
|
1774
|
+
|
|
1775
|
+
assert len(final_state["observations"]) == 12 # Updated: 12 phases now
|
|
1776
|
+
|
|
1777
|
+
|
|
1778
|
+
# =============================================================================
|
|
1779
|
+
# STORY-307: SubagentEnforcementError Tests (AC3)
|
|
1780
|
+
# =============================================================================
|
|
1781
|
+
|
|
1782
|
+
|
|
1783
|
+
class TestSubagentEnforcementError:
|
|
1784
|
+
"""Tests for AC3: SubagentEnforcementError exception behavior.
|
|
1785
|
+
|
|
1786
|
+
STORY-307: Tests for the new exception added in STORY-306.
|
|
1787
|
+
"""
|
|
1788
|
+
|
|
1789
|
+
def test_subagent_enforcement_error_inherits_phase_state_error(self):
|
|
1790
|
+
"""SubagentEnforcementError should inherit from PhaseStateError."""
|
|
1791
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError, PhaseStateError
|
|
1792
|
+
|
|
1793
|
+
assert issubclass(SubagentEnforcementError, PhaseStateError)
|
|
1794
|
+
|
|
1795
|
+
def test_subagent_enforcement_error_stores_story_id(self):
|
|
1796
|
+
"""SubagentEnforcementError should store story_id attribute."""
|
|
1797
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1798
|
+
|
|
1799
|
+
error = SubagentEnforcementError("STORY-001", "02", ["test-automator"])
|
|
1800
|
+
assert error.story_id == "STORY-001"
|
|
1801
|
+
|
|
1802
|
+
def test_subagent_enforcement_error_stores_phase(self):
|
|
1803
|
+
"""SubagentEnforcementError should store phase attribute."""
|
|
1804
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1805
|
+
|
|
1806
|
+
error = SubagentEnforcementError("STORY-001", "02", ["test-automator"])
|
|
1807
|
+
assert error.phase == "02"
|
|
1808
|
+
|
|
1809
|
+
def test_subagent_enforcement_error_stores_missing_subagents(self):
|
|
1810
|
+
"""SubagentEnforcementError should store missing_subagents attribute."""
|
|
1811
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1812
|
+
|
|
1813
|
+
error = SubagentEnforcementError("STORY-001", "04", ["refactoring-specialist", "code-reviewer"])
|
|
1814
|
+
assert error.missing_subagents == ["refactoring-specialist", "code-reviewer"]
|
|
1815
|
+
|
|
1816
|
+
def test_subagent_enforcement_error_message_contains_phase(self):
|
|
1817
|
+
"""Error message should contain phase identifier."""
|
|
1818
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1819
|
+
|
|
1820
|
+
error = SubagentEnforcementError("STORY-001", "02", ["test-automator"])
|
|
1821
|
+
assert "02" in str(error)
|
|
1822
|
+
|
|
1823
|
+
def test_subagent_enforcement_error_message_contains_missing_subagents(self):
|
|
1824
|
+
"""Error message should list missing subagent names."""
|
|
1825
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1826
|
+
|
|
1827
|
+
error = SubagentEnforcementError("STORY-001", "04", ["refactoring-specialist"])
|
|
1828
|
+
assert "refactoring-specialist" in str(error)
|
|
1829
|
+
|
|
1830
|
+
def test_complete_phase_raises_enforcement_error_when_missing(
|
|
1831
|
+
self, phase_state_with_existing_file
|
|
1832
|
+
):
|
|
1833
|
+
"""complete_phase should raise SubagentEnforcementError when subagents missing."""
|
|
1834
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1835
|
+
|
|
1836
|
+
ps = phase_state_with_existing_file
|
|
1837
|
+
# Don't record any subagents
|
|
1838
|
+
|
|
1839
|
+
with pytest.raises(SubagentEnforcementError) as exc_info:
|
|
1840
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
1841
|
+
|
|
1842
|
+
assert exc_info.value.story_id == "STORY-001"
|
|
1843
|
+
assert exc_info.value.phase == "01"
|
|
1844
|
+
assert "git-validator" in exc_info.value.missing_subagents
|
|
1845
|
+
|
|
1846
|
+
|
|
1847
|
+
# =============================================================================
|
|
1848
|
+
# STORY-307: PHASE_REQUIRED_SUBAGENTS Constant Tests (AC4)
|
|
1849
|
+
# =============================================================================
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
class TestPHASE_REQUIRED_SUBAGENTS:
|
|
1853
|
+
"""Tests for AC4: PHASE_REQUIRED_SUBAGENTS constant structure validation.
|
|
1854
|
+
|
|
1855
|
+
STORY-307: Validates the constant structure added in STORY-306.
|
|
1856
|
+
"""
|
|
1857
|
+
|
|
1858
|
+
def test_constant_exists_in_module(self):
|
|
1859
|
+
"""PHASE_REQUIRED_SUBAGENTS constant should exist."""
|
|
1860
|
+
from devforgeai_cli import phase_state
|
|
1861
|
+
|
|
1862
|
+
assert hasattr(phase_state, 'PHASE_REQUIRED_SUBAGENTS')
|
|
1863
|
+
|
|
1864
|
+
def test_constant_contains_all_12_phases(self):
|
|
1865
|
+
"""Constant should have entries for all 12 valid phases."""
|
|
1866
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1867
|
+
|
|
1868
|
+
expected = ["01", "02", "03", "04", "4.5", "05", "5.5", "06", "07", "08", "09", "10"]
|
|
1869
|
+
for phase in expected:
|
|
1870
|
+
assert phase in PHASE_REQUIRED_SUBAGENTS, f"Missing phase {phase}"
|
|
1871
|
+
|
|
1872
|
+
def test_phase_03_uses_tuple_for_or_logic(self):
|
|
1873
|
+
"""Phase 03 should use tuple for OR logic (backend/frontend)."""
|
|
1874
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1875
|
+
|
|
1876
|
+
phase_03 = PHASE_REQUIRED_SUBAGENTS["03"]
|
|
1877
|
+
has_tuple = any(isinstance(item, tuple) for item in phase_03)
|
|
1878
|
+
assert has_tuple, "Phase 03 should have tuple for OR logic"
|
|
1879
|
+
|
|
1880
|
+
def test_phase_03_tuple_contains_backend_and_frontend(self):
|
|
1881
|
+
"""Phase 03 tuple should contain both backend-architect and frontend-developer."""
|
|
1882
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1883
|
+
|
|
1884
|
+
phase_03 = PHASE_REQUIRED_SUBAGENTS["03"]
|
|
1885
|
+
for item in phase_03:
|
|
1886
|
+
if isinstance(item, tuple):
|
|
1887
|
+
assert "backend-architect" in item
|
|
1888
|
+
assert "frontend-developer" in item
|
|
1889
|
+
break
|
|
1890
|
+
else:
|
|
1891
|
+
pytest.fail("No tuple found in Phase 03 requirements")
|
|
1892
|
+
|
|
1893
|
+
def test_phase_09_includes_framework_analyst(self):
|
|
1894
|
+
"""Phase 09 should require framework-analyst (RCA-027 fix)."""
|
|
1895
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1896
|
+
|
|
1897
|
+
assert "framework-analyst" in PHASE_REQUIRED_SUBAGENTS["09"]
|
|
1898
|
+
|
|
1899
|
+
def test_phase_01_requires_git_and_tech_stack(self):
|
|
1900
|
+
"""Phase 01 should require git-validator and tech-stack-detector."""
|
|
1901
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1902
|
+
|
|
1903
|
+
assert "git-validator" in PHASE_REQUIRED_SUBAGENTS["01"]
|
|
1904
|
+
assert "tech-stack-detector" in PHASE_REQUIRED_SUBAGENTS["01"]
|
|
1905
|
+
|
|
1906
|
+
def test_phase_02_requires_test_automator(self):
|
|
1907
|
+
"""Phase 02 should require test-automator."""
|
|
1908
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1909
|
+
|
|
1910
|
+
assert "test-automator" in PHASE_REQUIRED_SUBAGENTS["02"]
|
|
1911
|
+
|
|
1912
|
+
def test_phase_06_07_08_empty_requirements(self):
|
|
1913
|
+
"""Phases 06, 07, 08 should have empty requirements (conditional/file/git ops)."""
|
|
1914
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1915
|
+
|
|
1916
|
+
assert PHASE_REQUIRED_SUBAGENTS["06"] == []
|
|
1917
|
+
assert PHASE_REQUIRED_SUBAGENTS["07"] == []
|
|
1918
|
+
assert PHASE_REQUIRED_SUBAGENTS["08"] == []
|
|
1919
|
+
|
|
1920
|
+
def test_ac_verification_phases_require_verifier(self):
|
|
1921
|
+
"""Phases 4.5 and 5.5 should require ac-compliance-verifier."""
|
|
1922
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
1923
|
+
|
|
1924
|
+
assert "ac-compliance-verifier" in PHASE_REQUIRED_SUBAGENTS["4.5"]
|
|
1925
|
+
assert "ac-compliance-verifier" in PHASE_REQUIRED_SUBAGENTS["5.5"]
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
# =============================================================================
|
|
1929
|
+
# STORY-307: OR Logic Phase 03 Tests (AC5)
|
|
1930
|
+
# =============================================================================
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
class TestORLogicPhase03:
|
|
1934
|
+
"""Tests for AC5: OR logic for Phase 03 subagent requirements.
|
|
1935
|
+
|
|
1936
|
+
STORY-307: Validates that either backend-architect OR frontend-developer
|
|
1937
|
+
satisfies Phase 03 architect requirement.
|
|
1938
|
+
"""
|
|
1939
|
+
|
|
1940
|
+
def _advance_to_phase_03(self, ps):
|
|
1941
|
+
"""Helper to advance state to Phase 03."""
|
|
1942
|
+
# Complete Phase 01
|
|
1943
|
+
ps.record_subagent("STORY-001", "01", "git-validator")
|
|
1944
|
+
ps.record_subagent("STORY-001", "01", "tech-stack-detector")
|
|
1945
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=True)
|
|
1946
|
+
|
|
1947
|
+
# Complete Phase 02
|
|
1948
|
+
ps.record_subagent("STORY-001", "02", "test-automator")
|
|
1949
|
+
ps.complete_phase("STORY-001", "02", checkpoint_passed=True)
|
|
1950
|
+
|
|
1951
|
+
def test_phase03_succeeds_with_backend_architect_only(
|
|
1952
|
+
self, phase_state_with_existing_file
|
|
1953
|
+
):
|
|
1954
|
+
"""Phase 03 should complete with only backend-architect."""
|
|
1955
|
+
ps = phase_state_with_existing_file
|
|
1956
|
+
self._advance_to_phase_03(ps)
|
|
1957
|
+
|
|
1958
|
+
# Phase 03: backend-architect only (no frontend-developer)
|
|
1959
|
+
ps.record_subagent("STORY-001", "03", "backend-architect")
|
|
1960
|
+
ps.record_subagent("STORY-001", "03", "context-validator")
|
|
1961
|
+
|
|
1962
|
+
# Should succeed without error
|
|
1963
|
+
ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
|
|
1964
|
+
state = ps.read("STORY-001")
|
|
1965
|
+
assert state["phases"]["03"]["status"] == "completed"
|
|
1966
|
+
|
|
1967
|
+
def test_phase03_succeeds_with_frontend_developer_only(
|
|
1968
|
+
self, phase_state_with_existing_file
|
|
1969
|
+
):
|
|
1970
|
+
"""Phase 03 should complete with only frontend-developer."""
|
|
1971
|
+
ps = phase_state_with_existing_file
|
|
1972
|
+
self._advance_to_phase_03(ps)
|
|
1973
|
+
|
|
1974
|
+
# Phase 03: frontend-developer only (no backend-architect)
|
|
1975
|
+
ps.record_subagent("STORY-001", "03", "frontend-developer")
|
|
1976
|
+
ps.record_subagent("STORY-001", "03", "context-validator")
|
|
1977
|
+
|
|
1978
|
+
# Should succeed without error
|
|
1979
|
+
ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
|
|
1980
|
+
state = ps.read("STORY-001")
|
|
1981
|
+
assert state["phases"]["03"]["status"] == "completed"
|
|
1982
|
+
|
|
1983
|
+
def test_phase03_fails_with_neither_architect_subagent(
|
|
1984
|
+
self, phase_state_with_existing_file
|
|
1985
|
+
):
|
|
1986
|
+
"""Phase 03 should fail when neither backend/frontend invoked."""
|
|
1987
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
1988
|
+
|
|
1989
|
+
ps = phase_state_with_existing_file
|
|
1990
|
+
self._advance_to_phase_03(ps)
|
|
1991
|
+
|
|
1992
|
+
# Phase 03: only context-validator (missing architect)
|
|
1993
|
+
ps.record_subagent("STORY-001", "03", "context-validator")
|
|
1994
|
+
|
|
1995
|
+
with pytest.raises(SubagentEnforcementError):
|
|
1996
|
+
ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
|
|
1997
|
+
|
|
1998
|
+
def test_phase03_succeeds_with_both_architect_subagents(
|
|
1999
|
+
self, phase_state_with_existing_file
|
|
2000
|
+
):
|
|
2001
|
+
"""Phase 03 should complete when both architects invoked (over-satisfaction)."""
|
|
2002
|
+
ps = phase_state_with_existing_file
|
|
2003
|
+
self._advance_to_phase_03(ps)
|
|
2004
|
+
|
|
2005
|
+
# Phase 03: both architects (over-satisfied)
|
|
2006
|
+
ps.record_subagent("STORY-001", "03", "backend-architect")
|
|
2007
|
+
ps.record_subagent("STORY-001", "03", "frontend-developer")
|
|
2008
|
+
ps.record_subagent("STORY-001", "03", "context-validator")
|
|
2009
|
+
|
|
2010
|
+
# Should succeed without error
|
|
2011
|
+
ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
|
|
2012
|
+
state = ps.read("STORY-001")
|
|
2013
|
+
assert state["phases"]["03"]["status"] == "completed"
|
|
2014
|
+
|
|
2015
|
+
def test_phase03_fails_missing_context_validator(
|
|
2016
|
+
self, phase_state_with_existing_file
|
|
2017
|
+
):
|
|
2018
|
+
"""Phase 03 should fail when context-validator missing (even with architect)."""
|
|
2019
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
2020
|
+
|
|
2021
|
+
ps = phase_state_with_existing_file
|
|
2022
|
+
self._advance_to_phase_03(ps)
|
|
2023
|
+
|
|
2024
|
+
# Phase 03: architect only (missing context-validator)
|
|
2025
|
+
ps.record_subagent("STORY-001", "03", "backend-architect")
|
|
2026
|
+
|
|
2027
|
+
with pytest.raises(SubagentEnforcementError) as exc_info:
|
|
2028
|
+
ps.complete_phase("STORY-001", "03", checkpoint_passed=True)
|
|
2029
|
+
|
|
2030
|
+
assert "context-validator" in str(exc_info.value)
|
|
2031
|
+
|
|
2032
|
+
|
|
2033
|
+
# =============================================================================
|
|
2034
|
+
# STORY-307: Escape Hatch Tests (AC6)
|
|
2035
|
+
# =============================================================================
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
class TestEscapeHatch:
|
|
2039
|
+
"""Tests for AC6: Escape hatch bypasses subagent enforcement.
|
|
2040
|
+
|
|
2041
|
+
STORY-307: Validates that checkpoint_passed=False bypasses
|
|
2042
|
+
subagent enforcement for emergency situations.
|
|
2043
|
+
"""
|
|
2044
|
+
|
|
2045
|
+
def test_escape_hatch_bypasses_enforcement(
|
|
2046
|
+
self, phase_state_with_existing_file
|
|
2047
|
+
):
|
|
2048
|
+
"""checkpoint_passed=False should bypass subagent enforcement."""
|
|
2049
|
+
ps = phase_state_with_existing_file
|
|
2050
|
+
|
|
2051
|
+
# Do NOT record required subagents
|
|
2052
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
|
|
2053
|
+
|
|
2054
|
+
state = ps.read("STORY-001")
|
|
2055
|
+
assert state["phases"]["01"]["status"] == "completed"
|
|
2056
|
+
assert state["phases"]["01"]["checkpoint_passed"] is False
|
|
2057
|
+
|
|
2058
|
+
def test_escape_hatch_no_subagent_enforcement_error(
|
|
2059
|
+
self, phase_state_with_existing_file
|
|
2060
|
+
):
|
|
2061
|
+
"""checkpoint_passed=False should not raise SubagentEnforcementError."""
|
|
2062
|
+
from devforgeai_cli.phase_state import SubagentEnforcementError
|
|
2063
|
+
|
|
2064
|
+
ps = phase_state_with_existing_file
|
|
2065
|
+
|
|
2066
|
+
# Should NOT raise even without required subagents
|
|
2067
|
+
try:
|
|
2068
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
|
|
2069
|
+
except SubagentEnforcementError:
|
|
2070
|
+
pytest.fail("Escape hatch should bypass SubagentEnforcementError")
|
|
2071
|
+
|
|
2072
|
+
def test_escape_hatch_advances_current_phase(
|
|
2073
|
+
self, phase_state_with_existing_file
|
|
2074
|
+
):
|
|
2075
|
+
"""Escape hatch should still advance current_phase."""
|
|
2076
|
+
ps = phase_state_with_existing_file
|
|
2077
|
+
|
|
2078
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
|
|
2079
|
+
|
|
2080
|
+
state = ps.read("STORY-001")
|
|
2081
|
+
assert state["current_phase"] == "02"
|
|
2082
|
+
|
|
2083
|
+
def test_escape_hatch_records_completed_at(
|
|
2084
|
+
self, phase_state_with_existing_file
|
|
2085
|
+
):
|
|
2086
|
+
"""Escape hatch should still record completed_at timestamp."""
|
|
2087
|
+
ps = phase_state_with_existing_file
|
|
2088
|
+
|
|
2089
|
+
ps.complete_phase("STORY-001", "01", checkpoint_passed=False)
|
|
2090
|
+
|
|
2091
|
+
state = ps.read("STORY-001")
|
|
2092
|
+
assert "completed_at" in state["phases"]["01"]
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
# =============================================================================
|
|
2096
|
+
# STORY-307: Backward Compatibility Tests (AC7)
|
|
2097
|
+
# =============================================================================
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
class TestBackwardCompatibility:
|
|
2101
|
+
"""Tests for AC7: Legacy state file migration.
|
|
2102
|
+
|
|
2103
|
+
STORY-307: Validates that legacy state files with empty subagents_required
|
|
2104
|
+
arrays are populated from PHASE_REQUIRED_SUBAGENTS on read.
|
|
2105
|
+
"""
|
|
2106
|
+
|
|
2107
|
+
def test_legacy_empty_arrays_populated_on_read(self, temp_project_root: Path):
|
|
2108
|
+
"""Legacy state with empty subagents_required should be populated."""
|
|
2109
|
+
from devforgeai_cli.phase_state import PhaseState, PHASE_REQUIRED_SUBAGENTS
|
|
2110
|
+
|
|
2111
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
2112
|
+
|
|
2113
|
+
# Create legacy state with empty arrays
|
|
2114
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
2115
|
+
workflows_dir.mkdir(parents=True)
|
|
2116
|
+
legacy_state = {
|
|
2117
|
+
"story_id": "STORY-001",
|
|
2118
|
+
"current_phase": "01",
|
|
2119
|
+
"workflow_started": "2026-01-24T00:00:00Z",
|
|
2120
|
+
"blocking_status": False,
|
|
2121
|
+
"phases": {
|
|
2122
|
+
f"{i:02d}": {
|
|
2123
|
+
"status": "pending",
|
|
2124
|
+
"subagents_required": [], # Legacy: empty
|
|
2125
|
+
"subagents_invoked": []
|
|
2126
|
+
} for i in range(1, 11)
|
|
2127
|
+
},
|
|
2128
|
+
"validation_errors": [],
|
|
2129
|
+
"observations": []
|
|
2130
|
+
}
|
|
2131
|
+
(workflows_dir / "STORY-001-phase-state.json").write_text(json.dumps(legacy_state))
|
|
2132
|
+
|
|
2133
|
+
# Read should populate subagents_required
|
|
2134
|
+
state = ps.read("STORY-001")
|
|
2135
|
+
|
|
2136
|
+
# Phase 01 should now have populated subagents_required
|
|
2137
|
+
assert state["phases"]["01"]["subagents_required"] != []
|
|
2138
|
+
assert "git-validator" in state["phases"]["01"]["subagents_required"]
|
|
2139
|
+
|
|
2140
|
+
def test_legacy_missing_decimal_phases_added(self, temp_project_root: Path):
|
|
2141
|
+
"""Legacy state missing 4.5/5.5 phases should have them added."""
|
|
2142
|
+
from devforgeai_cli.phase_state import PhaseState
|
|
2143
|
+
|
|
2144
|
+
ps = PhaseState(project_root=temp_project_root)
|
|
2145
|
+
|
|
2146
|
+
# Create legacy state without decimal phases
|
|
2147
|
+
workflows_dir = temp_project_root / "devforgeai" / "workflows"
|
|
2148
|
+
workflows_dir.mkdir(parents=True)
|
|
2149
|
+
legacy_state = {
|
|
2150
|
+
"story_id": "STORY-001",
|
|
2151
|
+
"current_phase": "01",
|
|
2152
|
+
"workflow_started": "2026-01-24T00:00:00Z",
|
|
2153
|
+
"blocking_status": False,
|
|
2154
|
+
"phases": {
|
|
2155
|
+
f"{i:02d}": {
|
|
2156
|
+
"status": "pending",
|
|
2157
|
+
"subagents_required": [],
|
|
2158
|
+
"subagents_invoked": []
|
|
2159
|
+
} for i in range(1, 11)
|
|
2160
|
+
},
|
|
2161
|
+
"validation_errors": [],
|
|
2162
|
+
"observations": []
|
|
2163
|
+
}
|
|
2164
|
+
(workflows_dir / "STORY-001-phase-state.json").write_text(json.dumps(legacy_state))
|
|
2165
|
+
|
|
2166
|
+
# Read should add missing decimal phases
|
|
2167
|
+
state = ps.read("STORY-001")
|
|
2168
|
+
|
|
2169
|
+
assert "4.5" in state["phases"]
|
|
2170
|
+
assert "5.5" in state["phases"]
|
|
2171
|
+
assert state["phases"]["4.5"]["status"] == "pending"
|
|
2172
|
+
assert state["phases"]["5.5"]["status"] == "pending"
|
|
2173
|
+
|
|
2174
|
+
def test_new_state_has_populated_subagents_required(self, phase_state):
|
|
2175
|
+
"""Newly created state should have populated subagents_required."""
|
|
2176
|
+
from devforgeai_cli.phase_state import PHASE_REQUIRED_SUBAGENTS
|
|
2177
|
+
|
|
2178
|
+
state = phase_state.create("STORY-001")
|
|
2179
|
+
|
|
2180
|
+
# Verify Phase 01 has populated requirements
|
|
2181
|
+
assert state["phases"]["01"]["subagents_required"] != []
|
|
2182
|
+
assert "git-validator" in state["phases"]["01"]["subagents_required"]
|
|
2183
|
+
|
|
2184
|
+
# Verify Phase 03 has OR logic (as list)
|
|
2185
|
+
phase_03_required = state["phases"]["03"]["subagents_required"]
|
|
2186
|
+
has_list = any(isinstance(item, list) for item in phase_03_required)
|
|
2187
|
+
assert has_list, "Phase 03 should have list (serialized tuple) for OR logic"
|