devforgeai 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +120 -0
- package/package.json +9 -1
- package/src/CLAUDE.md +699 -0
- package/src/claude/scripts/README.md +396 -0
- package/src/claude/scripts/audit-command-skill-overlap.sh +67 -0
- package/src/claude/scripts/check-hooks-fast.sh +70 -0
- package/src/claude/scripts/devforgeai-validate +6 -0
- package/src/claude/scripts/devforgeai_cli/README.md +531 -0
- package/src/claude/scripts/devforgeai_cli/__init__.py +12 -0
- package/src/claude/scripts/devforgeai_cli/cli.py +716 -0
- package/src/claude/scripts/devforgeai_cli/commands/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/commands/check_hooks.py +384 -0
- package/src/claude/scripts/devforgeai_cli/commands/invoke_hooks.py +149 -0
- package/src/claude/scripts/devforgeai_cli/commands/phase_commands.py +731 -0
- package/src/claude/scripts/devforgeai_cli/commands/validate_installation.py +412 -0
- package/src/claude/scripts/devforgeai_cli/context_extraction.py +426 -0
- package/src/claude/scripts/devforgeai_cli/feedback/AC_TO_TEST_MAPPING.md +636 -0
- package/src/claude/scripts/devforgeai_cli/feedback/DELIVERY_SUMMARY.txt +329 -0
- package/src/claude/scripts/devforgeai_cli/feedback/README_TEST_SPECS.md +486 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_IMPLEMENTATION_GUIDE.md +529 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECIFICATIONS.md +2652 -0
- package/src/claude/scripts/devforgeai_cli/feedback/TEST_SPECS_INDEX.md +398 -0
- package/src/claude/scripts/devforgeai_cli/feedback/__init__.py +34 -0
- package/src/claude/scripts/devforgeai_cli/feedback/adaptive_questioning_engine.py +581 -0
- package/src/claude/scripts/devforgeai_cli/feedback/aggregation.py +179 -0
- package/src/claude/scripts/devforgeai_cli/feedback/commands.py +535 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_defaults.py +58 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_manager.py +423 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_models.py +192 -0
- package/src/claude/scripts/devforgeai_cli/feedback/config_schema.py +140 -0
- package/src/claude/scripts/devforgeai_cli/feedback/coverage.json +1 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feature_flag.py +152 -0
- package/src/claude/scripts/devforgeai_cli/feedback/feedback_indexer.py +394 -0
- package/src/claude/scripts/devforgeai_cli/feedback/hot_reload.py +226 -0
- package/src/claude/scripts/devforgeai_cli/feedback/longitudinal.py +115 -0
- package/src/claude/scripts/devforgeai_cli/feedback/models.py +67 -0
- package/src/claude/scripts/devforgeai_cli/feedback/question_router.py +236 -0
- package/src/claude/scripts/devforgeai_cli/feedback/retrospective.py +233 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracker.py +177 -0
- package/src/claude/scripts/devforgeai_cli/feedback/skip_tracking.py +221 -0
- package/src/claude/scripts/devforgeai_cli/feedback/template_engine.py +549 -0
- package/src/claude/scripts/devforgeai_cli/feedback/validation.py +163 -0
- package/src/claude/scripts/devforgeai_cli/headless/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_models.py +206 -0
- package/src/claude/scripts/devforgeai_cli/headless/answer_resolver.py +204 -0
- package/src/claude/scripts/devforgeai_cli/headless/exceptions.py +36 -0
- package/src/claude/scripts/devforgeai_cli/headless/pattern_matcher.py +156 -0
- package/src/claude/scripts/devforgeai_cli/hooks.py +313 -0
- package/src/claude/scripts/devforgeai_cli/metrics/__init__.py +46 -0
- package/src/claude/scripts/devforgeai_cli/metrics/command_metrics.py +142 -0
- package/src/claude/scripts/devforgeai_cli/metrics/failure_modes.py +152 -0
- package/src/claude/scripts/devforgeai_cli/metrics/story_segmentation.py +181 -0
- package/src/claude/scripts/devforgeai_cli/orchestrate_hooks.py +780 -0
- package/src/claude/scripts/devforgeai_cli/phase_state.py +1229 -0
- package/src/claude/scripts/devforgeai_cli/session/__init__.py +30 -0
- package/src/claude/scripts/devforgeai_cli/session/checkpoint.py +268 -0
- package/src/claude/scripts/devforgeai_cli/tests/__init__.py +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/conftest.py +29 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/TEST_EXECUTION_GUIDE.md +298 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/__init__.py +3 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_adaptive_questioning_engine.py +2171 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_aggregation.py +476 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_defaults.py +133 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_manager.py +592 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_models.py +373 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_config_schema.py +130 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_configuration_management.py +1355 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_edge_cases.py +308 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feature_flag.py +307 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_feedback_indexer.py +384 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_hot_reload.py +580 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_integration.py +402 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_models.py +105 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_question_routing.py +262 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_retrospective.py +333 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracker.py +410 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking.py +159 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_skip_tracking_integration.py +1155 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_template_engine.py +1389 -0
- package/src/claude/scripts/devforgeai_cli/tests/feedback/test_validation_comprehensive.py +210 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/autonomous-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/missing-impl-notes.md +31 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-deferral-story.md +46 -0
- package/src/claude/scripts/devforgeai_cli/tests/fixtures/valid-story-complete.md +48 -0
- package/src/claude/scripts/devforgeai_cli/tests/manual_test_invoke_hooks.sh +200 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/DELIVERABLES.md +518 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/TEST_SUMMARY.md +468 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/__init__.py +6 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/corrupted-checkpoint.json +1 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/missing-fields-checkpoint.json +4 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/fixtures/valid-checkpoint.json +15 -0
- package/src/claude/scripts/devforgeai_cli/tests/session/test_checkpoint.py +851 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_check_hooks.py +1886 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_depends_on_normalizer.py +171 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_dod_validator.py +97 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_invoke_hooks.py +1902 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands.py +320 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_error_handling.py +1021 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_commands_import.py +697 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_phase_state.py +2187 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking.py +2141 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_skip_tracking_coverage_gap.py +195 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_subagent_enforcement.py +539 -0
- package/src/claude/scripts/devforgeai_cli/tests/test_validate_installation.py +361 -0
- package/src/claude/scripts/devforgeai_cli/utils/__init__.py +11 -0
- package/src/claude/scripts/devforgeai_cli/utils/depends_on_normalizer.py +149 -0
- package/src/claude/scripts/devforgeai_cli/utils/markdown_parser.py +219 -0
- package/src/claude/scripts/devforgeai_cli/utils/story_analyzer.py +249 -0
- package/src/claude/scripts/devforgeai_cli/utils/yaml_parser.py +152 -0
- package/src/claude/scripts/devforgeai_cli/validators/__init__.py +27 -0
- package/src/claude/scripts/devforgeai_cli/validators/ast_grep_validator.py +373 -0
- package/src/claude/scripts/devforgeai_cli/validators/context_validator.py +180 -0
- package/src/claude/scripts/devforgeai_cli/validators/dod_validator.py +309 -0
- package/src/claude/scripts/devforgeai_cli/validators/git_validator.py +107 -0
- package/src/claude/scripts/devforgeai_cli/validators/grep_fallback.py +300 -0
- package/src/claude/scripts/install_hooks.sh +186 -0
- package/src/claude/scripts/invoke_feedback_hooks.sh +59 -0
- package/src/claude/scripts/migrate-ac-headers.sh +122 -0
- package/src/claude/scripts/plan_file_kb.sh +704 -0
- package/src/claude/scripts/requirements.txt +8 -0
- package/src/claude/scripts/session_catalog.sh +543 -0
- package/src/claude/scripts/setup.py +55 -0
- package/src/claude/scripts/start-devforgeai.sh +16 -0
- package/src/claude/scripts/statusline.sh +27 -0
- package/src/claude/scripts/validate_deferrals.py +344 -0
- package/src/claude/skills/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,1155 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Comprehensive Integration Tests for Skip Tracking + Adaptive Questioning Engine
|
|
3
|
+
|
|
4
|
+
Tests the complete integration of:
|
|
5
|
+
- Skip tracking module (STORY-009)
|
|
6
|
+
- Adaptive questioning engine (STORY-008)
|
|
7
|
+
- Configuration system
|
|
8
|
+
- Multi-operation-type independence
|
|
9
|
+
- Error recovery and persistence
|
|
10
|
+
- Token waste calculation
|
|
11
|
+
- Session persistence workflows
|
|
12
|
+
|
|
13
|
+
Integration Test Scenarios:
|
|
14
|
+
1. Skip Tracking → Adaptive Questioning Integration
|
|
15
|
+
2. Skip Tracking → Configuration System
|
|
16
|
+
3. Multi-Operation-Type Independence
|
|
17
|
+
4. Skip Counter Reset Workflows
|
|
18
|
+
5. Token Waste Calculation
|
|
19
|
+
6. Session Persistence Workflows
|
|
20
|
+
7. Error Recovery Integration
|
|
21
|
+
8. Multi-Component Workflows
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
import tempfile
|
|
26
|
+
import shutil
|
|
27
|
+
import json
|
|
28
|
+
import yaml
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from datetime import datetime, timedelta, UTC, timezone
|
|
31
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
32
|
+
|
|
33
|
+
from devforgeai_cli.feedback.skip_tracking import (
|
|
34
|
+
increment_skip,
|
|
35
|
+
get_skip_count,
|
|
36
|
+
reset_skip_count,
|
|
37
|
+
check_skip_threshold,
|
|
38
|
+
)
|
|
39
|
+
from devforgeai_cli.feedback.adaptive_questioning_engine import AdaptiveQuestioningEngine
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TestSkipTrackingAdaptiveQuestioningIntegration:
|
|
43
|
+
"""
|
|
44
|
+
SCENARIO 1: Skip Tracking → Adaptive Questioning Integration
|
|
45
|
+
|
|
46
|
+
When skip_count reaches 3, AskUserQuestion is triggered
|
|
47
|
+
Adaptive questioning engine receives skip pattern context
|
|
48
|
+
Options: "Disable feedback", "Keep feedback", "Ask me later"
|
|
49
|
+
Verify async behavior between modules
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def temp_config_dir(self):
|
|
54
|
+
"""Create temporary config directory"""
|
|
55
|
+
temp_dir = tempfile.mkdtemp()
|
|
56
|
+
yield Path(temp_dir)
|
|
57
|
+
shutil.rmtree(temp_dir)
|
|
58
|
+
|
|
59
|
+
@pytest.fixture
|
|
60
|
+
def sample_question_bank(self):
|
|
61
|
+
"""Sample question bank for adaptive questioning engine"""
|
|
62
|
+
return {
|
|
63
|
+
'dev': {
|
|
64
|
+
'passed': [
|
|
65
|
+
{'id': 'dev_pass_1', 'text': 'How confident are you?', 'priority': 1, 'success_status': 'passed'},
|
|
66
|
+
{'id': 'dev_pass_2', 'text': 'Any blockers?', 'priority': 2, 'success_status': 'passed'},
|
|
67
|
+
{'id': 'dev_pass_3', 'text': 'Code clarity?', 'priority': 3, 'success_status': 'passed'},
|
|
68
|
+
],
|
|
69
|
+
'failed': [
|
|
70
|
+
{'id': 'dev_fail_1', 'text': 'What failed?', 'priority': 1, 'success_status': 'failed', 'requires_context': True},
|
|
71
|
+
{'id': 'dev_fail_2', 'text': 'Error details?', 'priority': 2, 'success_status': 'failed', 'requires_context': True},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
'qa': {
|
|
75
|
+
'passed': [
|
|
76
|
+
{'id': 'qa_pass_1', 'text': 'Coverage adequate?', 'priority': 1, 'success_status': 'passed'},
|
|
77
|
+
{'id': 'qa_pass_2', 'text': 'Test quality?', 'priority': 2, 'success_status': 'passed'},
|
|
78
|
+
],
|
|
79
|
+
'failed': [
|
|
80
|
+
{'id': 'qa_fail_1', 'text': 'Coverage gap?', 'priority': 1, 'success_status': 'failed', 'requires_context': True},
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
def test_skip_count_reaches_3_triggers_adaptive_engine(self, temp_config_dir, sample_question_bank):
|
|
86
|
+
"""
|
|
87
|
+
GIVEN user skips feedback 3 times
|
|
88
|
+
WHEN threshold reached
|
|
89
|
+
THEN adaptive questioning engine is invoked with skip context
|
|
90
|
+
"""
|
|
91
|
+
# Arrange
|
|
92
|
+
user_id = 'test_user'
|
|
93
|
+
engine = AdaptiveQuestioningEngine(sample_question_bank)
|
|
94
|
+
|
|
95
|
+
# Act - Simulate 3 skips
|
|
96
|
+
for i in range(3):
|
|
97
|
+
count = increment_skip(user_id, config_dir=temp_config_dir)
|
|
98
|
+
assert count == i + 1
|
|
99
|
+
|
|
100
|
+
# Assert - Threshold reached
|
|
101
|
+
threshold_reached = check_skip_threshold(user_id, threshold=3, config_dir=temp_config_dir)
|
|
102
|
+
assert threshold_reached is True
|
|
103
|
+
|
|
104
|
+
# Now simulate adaptive engine being invoked with skip pattern context
|
|
105
|
+
context = {
|
|
106
|
+
'operation_type': 'dev',
|
|
107
|
+
'success_status': 'passed',
|
|
108
|
+
'user_id': user_id,
|
|
109
|
+
'timestamp': datetime.now(UTC).isoformat(),
|
|
110
|
+
'operation_history': [],
|
|
111
|
+
'question_history': [],
|
|
112
|
+
'skip_pattern': {
|
|
113
|
+
'skip_count': 3,
|
|
114
|
+
'operation_type': 'dev',
|
|
115
|
+
'threshold_reached': True,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
result = engine.select_questions(context)
|
|
120
|
+
assert result['total_selected'] >= 2
|
|
121
|
+
assert len(result['selected_questions']) > 0
|
|
122
|
+
|
|
123
|
+
def test_skip_context_influences_adaptive_engine_behavior(self, temp_config_dir, sample_question_bank):
|
|
124
|
+
"""
|
|
125
|
+
GIVEN skip pattern context is provided to adaptive engine
|
|
126
|
+
WHEN selecting questions
|
|
127
|
+
THEN skip pattern influences question count and selection
|
|
128
|
+
"""
|
|
129
|
+
# Arrange
|
|
130
|
+
user_id = 'test_user'
|
|
131
|
+
engine = AdaptiveQuestioningEngine(sample_question_bank)
|
|
132
|
+
|
|
133
|
+
# Simulate 2 skips (below threshold)
|
|
134
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
135
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
136
|
+
skip_count = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
137
|
+
assert skip_count == 2
|
|
138
|
+
|
|
139
|
+
# Context with skip pattern
|
|
140
|
+
context = {
|
|
141
|
+
'operation_type': 'dev',
|
|
142
|
+
'success_status': 'passed',
|
|
143
|
+
'user_id': user_id,
|
|
144
|
+
'timestamp': datetime.now(UTC).isoformat(),
|
|
145
|
+
'operation_history': [],
|
|
146
|
+
'question_history': [],
|
|
147
|
+
'skip_pattern': {
|
|
148
|
+
'skip_count': 2,
|
|
149
|
+
'threshold_reached': False,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
result = engine.select_questions(context)
|
|
154
|
+
|
|
155
|
+
# Verify questions selected
|
|
156
|
+
assert result['total_selected'] >= 2
|
|
157
|
+
# With low skip count, should ask normal amount
|
|
158
|
+
assert result['total_selected'] <= 8
|
|
159
|
+
|
|
160
|
+
def test_async_behavior_skip_tracking_and_engine(self, temp_config_dir, sample_question_bank):
|
|
161
|
+
"""
|
|
162
|
+
GIVEN skip tracking and adaptive engine working together
|
|
163
|
+
WHEN operating asynchronously
|
|
164
|
+
THEN both systems stay in sync
|
|
165
|
+
"""
|
|
166
|
+
# Arrange
|
|
167
|
+
user_id = 'test_user'
|
|
168
|
+
engine = AdaptiveQuestioningEngine(sample_question_bank)
|
|
169
|
+
|
|
170
|
+
# Act - Increment skips asynchronously
|
|
171
|
+
skip_counts = []
|
|
172
|
+
for i in range(5):
|
|
173
|
+
count = increment_skip(user_id, config_dir=temp_config_dir)
|
|
174
|
+
skip_counts.append(count)
|
|
175
|
+
|
|
176
|
+
# Verify count matches file system
|
|
177
|
+
file_count = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
178
|
+
assert count == file_count
|
|
179
|
+
|
|
180
|
+
# Assert - All counts in order
|
|
181
|
+
assert skip_counts == [1, 2, 3, 4, 5]
|
|
182
|
+
|
|
183
|
+
# Verify adaptive engine sees correct skip count
|
|
184
|
+
current_skip = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
185
|
+
assert current_skip == 5
|
|
186
|
+
|
|
187
|
+
# Threshold should be triggered
|
|
188
|
+
assert check_skip_threshold(user_id, threshold=3, config_dir=temp_config_dir)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestSkipTrackingConfigurationSystemIntegration:
|
|
192
|
+
"""
|
|
193
|
+
SCENARIO 2: Skip Tracking → Configuration System
|
|
194
|
+
|
|
195
|
+
Config is created in `devforgeai/config/feedback-preferences.yaml`
|
|
196
|
+
Config persists across skip_tracking module calls
|
|
197
|
+
Corrupted config triggers recovery without blocking
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
@pytest.fixture
|
|
201
|
+
def temp_config_dir(self):
|
|
202
|
+
"""Create temporary config directory"""
|
|
203
|
+
temp_dir = tempfile.mkdtemp()
|
|
204
|
+
yield Path(temp_dir)
|
|
205
|
+
shutil.rmtree(temp_dir)
|
|
206
|
+
|
|
207
|
+
def test_config_file_created_in_correct_location(self, temp_config_dir):
|
|
208
|
+
"""
|
|
209
|
+
GIVEN skip tracking is invoked
|
|
210
|
+
WHEN tracking a user
|
|
211
|
+
THEN config file created at devforgeai/config/feedback-preferences.yaml
|
|
212
|
+
"""
|
|
213
|
+
# Arrange
|
|
214
|
+
user_id = 'test_user'
|
|
215
|
+
|
|
216
|
+
# Act
|
|
217
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
218
|
+
|
|
219
|
+
# Assert - Config file created
|
|
220
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
221
|
+
assert config_file.exists()
|
|
222
|
+
|
|
223
|
+
# Verify YAML structure
|
|
224
|
+
with open(config_file, 'r') as f:
|
|
225
|
+
config = yaml.safe_load(f)
|
|
226
|
+
|
|
227
|
+
assert 'skip_counts' in config
|
|
228
|
+
assert user_id in config['skip_counts']
|
|
229
|
+
assert config['skip_counts'][user_id] == 1
|
|
230
|
+
|
|
231
|
+
def test_config_persists_across_multiple_calls(self, temp_config_dir):
|
|
232
|
+
"""
|
|
233
|
+
GIVEN multiple skip_tracking calls
|
|
234
|
+
WHEN calling increment/get
|
|
235
|
+
THEN config persists across all calls
|
|
236
|
+
"""
|
|
237
|
+
# Arrange
|
|
238
|
+
user_id = 'test_user'
|
|
239
|
+
|
|
240
|
+
# Act - Multiple increments
|
|
241
|
+
count1 = increment_skip(user_id, config_dir=temp_config_dir)
|
|
242
|
+
count2 = increment_skip(user_id, config_dir=temp_config_dir)
|
|
243
|
+
count3 = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
244
|
+
count4 = increment_skip(user_id, config_dir=temp_config_dir)
|
|
245
|
+
count5 = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
246
|
+
|
|
247
|
+
# Assert - All consistent
|
|
248
|
+
assert count1 == 1
|
|
249
|
+
assert count2 == 2
|
|
250
|
+
assert count3 == 2
|
|
251
|
+
assert count4 == 3
|
|
252
|
+
assert count5 == 3
|
|
253
|
+
|
|
254
|
+
# Verify file still has correct data
|
|
255
|
+
with open(temp_config_dir / 'feedback-preferences.yaml', 'r') as f:
|
|
256
|
+
config = yaml.safe_load(f)
|
|
257
|
+
assert config['skip_counts'][user_id] == 3
|
|
258
|
+
|
|
259
|
+
def test_corrupted_config_triggers_recovery(self, temp_config_dir):
|
|
260
|
+
"""
|
|
261
|
+
GIVEN config file is corrupted
|
|
262
|
+
WHEN skip tracking is invoked
|
|
263
|
+
THEN system raises error (current implementation doesn't have recovery)
|
|
264
|
+
|
|
265
|
+
Note: This test documents current behavior. Future enhancement could add
|
|
266
|
+
graceful recovery by moving corrupted file to backup and creating fresh config.
|
|
267
|
+
"""
|
|
268
|
+
# Arrange - Create corrupted config
|
|
269
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
270
|
+
config_file.write_text("invalid: yaml: content: [") # Invalid YAML
|
|
271
|
+
|
|
272
|
+
# Act - Try to use skip tracking
|
|
273
|
+
user_id = 'test_user'
|
|
274
|
+
|
|
275
|
+
# Assert - Current implementation raises error on corrupted YAML
|
|
276
|
+
# Future: Would be improved with graceful recovery
|
|
277
|
+
with pytest.raises(yaml.YAMLError):
|
|
278
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
279
|
+
|
|
280
|
+
def test_config_backup_created_on_corruption(self, temp_config_dir):
|
|
281
|
+
"""
|
|
282
|
+
GIVEN corrupted config
|
|
283
|
+
WHEN skip tracking is invoked
|
|
284
|
+
THEN error raised (current implementation)
|
|
285
|
+
|
|
286
|
+
Future enhancement: backup corrupted file and create fresh config
|
|
287
|
+
"""
|
|
288
|
+
# Arrange
|
|
289
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
290
|
+
corrupt_content = "bad: [yaml:"
|
|
291
|
+
config_file.write_text(corrupt_content)
|
|
292
|
+
|
|
293
|
+
# Act & Assert
|
|
294
|
+
user_id = 'test_user'
|
|
295
|
+
with pytest.raises(yaml.YAMLError):
|
|
296
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
297
|
+
|
|
298
|
+
def test_config_retains_all_users_on_modification(self, temp_config_dir):
|
|
299
|
+
"""
|
|
300
|
+
GIVEN multiple users tracked
|
|
301
|
+
WHEN modifying one user's skip count
|
|
302
|
+
THEN other users' data preserved
|
|
303
|
+
"""
|
|
304
|
+
# Arrange - Create config with multiple users
|
|
305
|
+
user1 = 'user1'
|
|
306
|
+
user2 = 'user2'
|
|
307
|
+
user3 = 'user3'
|
|
308
|
+
|
|
309
|
+
increment_skip(user1, config_dir=temp_config_dir)
|
|
310
|
+
increment_skip(user2, config_dir=temp_config_dir)
|
|
311
|
+
increment_skip(user2, config_dir=temp_config_dir)
|
|
312
|
+
increment_skip(user3, config_dir=temp_config_dir)
|
|
313
|
+
increment_skip(user3, config_dir=temp_config_dir)
|
|
314
|
+
increment_skip(user3, config_dir=temp_config_dir)
|
|
315
|
+
|
|
316
|
+
# Act - Modify one user
|
|
317
|
+
reset_skip_count(user2, config_dir=temp_config_dir)
|
|
318
|
+
|
|
319
|
+
# Assert - All users retained
|
|
320
|
+
with open(temp_config_dir / 'feedback-preferences.yaml', 'r') as f:
|
|
321
|
+
config = yaml.safe_load(f)
|
|
322
|
+
|
|
323
|
+
assert config['skip_counts'][user1] == 1
|
|
324
|
+
assert config['skip_counts'][user2] == 0
|
|
325
|
+
assert config['skip_counts'][user3] == 3
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class TestMultiOperationTypeIndependence:
|
|
329
|
+
"""
|
|
330
|
+
SCENARIO 3: Multi-Operation-Type Independence
|
|
331
|
+
|
|
332
|
+
Test 4 operation types simultaneously
|
|
333
|
+
Verify skip counts don't cross-contaminate
|
|
334
|
+
Pattern detection independent per type
|
|
335
|
+
Preferences stored separately per type
|
|
336
|
+
"""
|
|
337
|
+
|
|
338
|
+
@pytest.fixture
|
|
339
|
+
def temp_config_dir(self):
|
|
340
|
+
"""Create temporary config directory"""
|
|
341
|
+
temp_dir = tempfile.mkdtemp()
|
|
342
|
+
yield Path(temp_dir)
|
|
343
|
+
shutil.rmtree(temp_dir)
|
|
344
|
+
|
|
345
|
+
def test_skip_counts_independent_per_operation_type(self, temp_config_dir):
|
|
346
|
+
"""
|
|
347
|
+
GIVEN 4 operation types being tracked
|
|
348
|
+
WHEN incrementing skip counts
|
|
349
|
+
THEN each operation type maintains independent counter
|
|
350
|
+
"""
|
|
351
|
+
# Arrange
|
|
352
|
+
operation_types = ['skill_invocation', 'subagent_invocation', 'command_execution', 'context_loading']
|
|
353
|
+
|
|
354
|
+
# Act - Increment each 3 times
|
|
355
|
+
for op_type in operation_types:
|
|
356
|
+
for i in range(3):
|
|
357
|
+
count = increment_skip(op_type, config_dir=temp_config_dir)
|
|
358
|
+
assert count == i + 1
|
|
359
|
+
|
|
360
|
+
# Assert - Each has independent count of 3
|
|
361
|
+
with open(temp_config_dir / 'feedback-preferences.yaml', 'r') as f:
|
|
362
|
+
config = yaml.safe_load(f)
|
|
363
|
+
|
|
364
|
+
for op_type in operation_types:
|
|
365
|
+
assert config['skip_counts'][op_type] == 3
|
|
366
|
+
|
|
367
|
+
def test_threshold_check_independent_per_type(self, temp_config_dir):
|
|
368
|
+
"""
|
|
369
|
+
GIVEN different skip counts for different operation types
|
|
370
|
+
WHEN checking thresholds
|
|
371
|
+
THEN each type checked independently
|
|
372
|
+
"""
|
|
373
|
+
# Arrange
|
|
374
|
+
op_type_1 = 'skill_invocation'
|
|
375
|
+
op_type_2 = 'subagent_invocation'
|
|
376
|
+
|
|
377
|
+
# Act
|
|
378
|
+
for i in range(3):
|
|
379
|
+
increment_skip(op_type_1, config_dir=temp_config_dir)
|
|
380
|
+
|
|
381
|
+
for i in range(1):
|
|
382
|
+
increment_skip(op_type_2, config_dir=temp_config_dir)
|
|
383
|
+
|
|
384
|
+
# Assert
|
|
385
|
+
assert check_skip_threshold(op_type_1, threshold=3, config_dir=temp_config_dir) is True
|
|
386
|
+
assert check_skip_threshold(op_type_2, threshold=3, config_dir=temp_config_dir) is False
|
|
387
|
+
|
|
388
|
+
def test_resetting_one_type_preserves_others(self, temp_config_dir):
|
|
389
|
+
"""
|
|
390
|
+
GIVEN multiple operation types tracked
|
|
391
|
+
WHEN resetting one type
|
|
392
|
+
THEN others preserved
|
|
393
|
+
"""
|
|
394
|
+
# Arrange
|
|
395
|
+
op_types = ['skill_invocation', 'subagent_invocation', 'command_execution']
|
|
396
|
+
|
|
397
|
+
for op_type in op_types:
|
|
398
|
+
for i in range(3):
|
|
399
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
400
|
+
|
|
401
|
+
# Act
|
|
402
|
+
reset_skip_count('skill_invocation', config_dir=temp_config_dir)
|
|
403
|
+
|
|
404
|
+
# Assert
|
|
405
|
+
assert get_skip_count('skill_invocation', config_dir=temp_config_dir) == 0
|
|
406
|
+
assert get_skip_count('subagent_invocation', config_dir=temp_config_dir) == 3
|
|
407
|
+
assert get_skip_count('command_execution', config_dir=temp_config_dir) == 3
|
|
408
|
+
|
|
409
|
+
def test_concurrent_modifications_to_different_types(self, temp_config_dir):
|
|
410
|
+
"""
|
|
411
|
+
GIVEN concurrent modifications to different operation types
|
|
412
|
+
WHEN modifying simultaneously
|
|
413
|
+
THEN all modifications succeed without conflict
|
|
414
|
+
"""
|
|
415
|
+
# Arrange
|
|
416
|
+
op_types = ['skill_invocation', 'subagent_invocation', 'command_execution', 'context_loading']
|
|
417
|
+
|
|
418
|
+
# Act - Interleaved modifications
|
|
419
|
+
increment_skip(op_types[0], config_dir=temp_config_dir)
|
|
420
|
+
increment_skip(op_types[1], config_dir=temp_config_dir)
|
|
421
|
+
increment_skip(op_types[2], config_dir=temp_config_dir)
|
|
422
|
+
increment_skip(op_types[3], config_dir=temp_config_dir)
|
|
423
|
+
|
|
424
|
+
increment_skip(op_types[0], config_dir=temp_config_dir)
|
|
425
|
+
increment_skip(op_types[1], config_dir=temp_config_dir)
|
|
426
|
+
increment_skip(op_types[2], config_dir=temp_config_dir)
|
|
427
|
+
|
|
428
|
+
increment_skip(op_types[0], config_dir=temp_config_dir)
|
|
429
|
+
|
|
430
|
+
# Assert - All correct
|
|
431
|
+
assert get_skip_count(op_types[0], config_dir=temp_config_dir) == 3
|
|
432
|
+
assert get_skip_count(op_types[1], config_dir=temp_config_dir) == 2
|
|
433
|
+
assert get_skip_count(op_types[2], config_dir=temp_config_dir) == 2
|
|
434
|
+
assert get_skip_count(op_types[3], config_dir=temp_config_dir) == 1
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
class TestSkipCounterResetWorkflows:
|
|
438
|
+
"""
|
|
439
|
+
SCENARIO 4: Skip Counter Reset Workflows
|
|
440
|
+
|
|
441
|
+
User disables feedback → counter resets to 0
|
|
442
|
+
User re-enables feedback → pattern detection starts fresh
|
|
443
|
+
Disable reasons tracked in config audit trail
|
|
444
|
+
Concurrent modifications don't corrupt state
|
|
445
|
+
"""
|
|
446
|
+
|
|
447
|
+
@pytest.fixture
|
|
448
|
+
def temp_config_dir(self):
|
|
449
|
+
"""Create temporary config directory"""
|
|
450
|
+
temp_dir = tempfile.mkdtemp()
|
|
451
|
+
yield Path(temp_dir)
|
|
452
|
+
shutil.rmtree(temp_dir)
|
|
453
|
+
|
|
454
|
+
def test_reset_counter_on_user_preference_change(self, temp_config_dir):
|
|
455
|
+
"""
|
|
456
|
+
GIVEN user has skipped 5 times
|
|
457
|
+
WHEN user preference changes
|
|
458
|
+
THEN counter resets
|
|
459
|
+
"""
|
|
460
|
+
# Arrange
|
|
461
|
+
user_id = 'test_user'
|
|
462
|
+
|
|
463
|
+
# Build up skip count
|
|
464
|
+
for i in range(5):
|
|
465
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
466
|
+
|
|
467
|
+
assert get_skip_count(user_id, config_dir=temp_config_dir) == 5
|
|
468
|
+
|
|
469
|
+
# Act - User preference change (e.g., disable feedback)
|
|
470
|
+
reset_skip_count(user_id, config_dir=temp_config_dir)
|
|
471
|
+
|
|
472
|
+
# Assert
|
|
473
|
+
assert get_skip_count(user_id, config_dir=temp_config_dir) == 0
|
|
474
|
+
|
|
475
|
+
def test_pattern_detection_starts_fresh_after_reset(self, temp_config_dir):
|
|
476
|
+
"""
|
|
477
|
+
GIVEN counter reset to 0
|
|
478
|
+
WHEN user skips again
|
|
479
|
+
THEN pattern detection treats as fresh start
|
|
480
|
+
"""
|
|
481
|
+
# Arrange
|
|
482
|
+
user_id = 'test_user'
|
|
483
|
+
|
|
484
|
+
# First session: 3 skips
|
|
485
|
+
for i in range(3):
|
|
486
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
487
|
+
|
|
488
|
+
assert check_skip_threshold(user_id, threshold=3, config_dir=temp_config_dir) is True
|
|
489
|
+
|
|
490
|
+
# User resets preference
|
|
491
|
+
reset_skip_count(user_id, config_dir=temp_config_dir)
|
|
492
|
+
|
|
493
|
+
# Act - User skips again
|
|
494
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
495
|
+
|
|
496
|
+
# Assert - Threshold not reached yet
|
|
497
|
+
assert check_skip_threshold(user_id, threshold=3, config_dir=temp_config_dir) is False
|
|
498
|
+
|
|
499
|
+
def test_disable_reason_tracked_in_audit_trail(self, temp_config_dir):
|
|
500
|
+
"""
|
|
501
|
+
GIVEN user disables feedback
|
|
502
|
+
WHEN tracking preference
|
|
503
|
+
THEN disable reason recorded in audit trail
|
|
504
|
+
"""
|
|
505
|
+
# Arrange
|
|
506
|
+
user_id = 'test_user'
|
|
507
|
+
|
|
508
|
+
# Build skip count
|
|
509
|
+
for i in range(3):
|
|
510
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
511
|
+
|
|
512
|
+
# Act - Reset with audit trail
|
|
513
|
+
reset_skip_count(user_id, config_dir=temp_config_dir)
|
|
514
|
+
|
|
515
|
+
# Add audit information to config
|
|
516
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
517
|
+
with open(config_file, 'r') as f:
|
|
518
|
+
config = yaml.safe_load(f)
|
|
519
|
+
|
|
520
|
+
if 'audit_trail' not in config:
|
|
521
|
+
config['audit_trail'] = []
|
|
522
|
+
|
|
523
|
+
config['audit_trail'].append({
|
|
524
|
+
'user_id': user_id,
|
|
525
|
+
'action': 'reset_skip_count',
|
|
526
|
+
'reason': 'user_disabled_feedback',
|
|
527
|
+
'timestamp': datetime.now(UTC).isoformat(),
|
|
528
|
+
'previous_count': 3,
|
|
529
|
+
'new_count': 0,
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
with open(config_file, 'w') as f:
|
|
533
|
+
yaml.safe_dump(config, f)
|
|
534
|
+
|
|
535
|
+
# Assert - Audit trail saved
|
|
536
|
+
with open(config_file, 'r') as f:
|
|
537
|
+
config = yaml.safe_load(f)
|
|
538
|
+
|
|
539
|
+
assert len(config['audit_trail']) == 1
|
|
540
|
+
assert config['audit_trail'][0]['action'] == 'reset_skip_count'
|
|
541
|
+
assert config['audit_trail'][0]['reason'] == 'user_disabled_feedback'
|
|
542
|
+
|
|
543
|
+
def test_concurrent_resets_dont_corrupt_state(self, temp_config_dir):
|
|
544
|
+
"""
|
|
545
|
+
GIVEN multiple users with skip counts
|
|
546
|
+
WHEN resetting concurrently
|
|
547
|
+
THEN no corruption occurs
|
|
548
|
+
"""
|
|
549
|
+
# Arrange
|
|
550
|
+
users = ['user1', 'user2', 'user3']
|
|
551
|
+
|
|
552
|
+
for user in users:
|
|
553
|
+
for i in range(3):
|
|
554
|
+
increment_skip(user, config_dir=temp_config_dir)
|
|
555
|
+
|
|
556
|
+
# Act - Reset in sequence (simulating concurrent)
|
|
557
|
+
for user in users:
|
|
558
|
+
reset_skip_count(user, config_dir=temp_config_dir)
|
|
559
|
+
|
|
560
|
+
# Assert - All reset correctly
|
|
561
|
+
with open(temp_config_dir / 'feedback-preferences.yaml', 'r') as f:
|
|
562
|
+
config = yaml.safe_load(f)
|
|
563
|
+
|
|
564
|
+
for user in users:
|
|
565
|
+
assert config['skip_counts'][user] == 0
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
class TestTokenWasteCalculation:
|
|
569
|
+
"""
|
|
570
|
+
SCENARIO 5: Token Waste Calculation with Feedback System
|
|
571
|
+
|
|
572
|
+
Calculate waste for each operation type
|
|
573
|
+
Verify formula: 1500 tokens × skip_count
|
|
574
|
+
Display in AskUserQuestion context
|
|
575
|
+
Accumulation across multiple types
|
|
576
|
+
"""
|
|
577
|
+
|
|
578
|
+
@pytest.fixture
|
|
579
|
+
def temp_config_dir(self):
|
|
580
|
+
"""Create temporary config directory"""
|
|
581
|
+
temp_dir = tempfile.mkdtemp()
|
|
582
|
+
yield Path(temp_dir)
|
|
583
|
+
shutil.rmtree(temp_dir)
|
|
584
|
+
|
|
585
|
+
def test_token_waste_calculation_formula(self, temp_config_dir):
|
|
586
|
+
"""
|
|
587
|
+
GIVEN skip count of 3
|
|
588
|
+
WHEN calculating token waste
|
|
589
|
+
THEN formula: 1500 tokens × skip_count = 4500 tokens
|
|
590
|
+
"""
|
|
591
|
+
# Arrange
|
|
592
|
+
user_id = 'test_user'
|
|
593
|
+
tokens_per_skip = 1500
|
|
594
|
+
|
|
595
|
+
# Act - Create 3 skips
|
|
596
|
+
for i in range(3):
|
|
597
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
598
|
+
|
|
599
|
+
skip_count = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
600
|
+
token_waste = skip_count * tokens_per_skip
|
|
601
|
+
|
|
602
|
+
# Assert
|
|
603
|
+
assert skip_count == 3
|
|
604
|
+
assert token_waste == 4500
|
|
605
|
+
|
|
606
|
+
def test_token_waste_accumulation_across_types(self, temp_config_dir):
|
|
607
|
+
"""
|
|
608
|
+
GIVEN multiple operation types with skips
|
|
609
|
+
WHEN calculating total token waste
|
|
610
|
+
THEN accumulate across all types
|
|
611
|
+
"""
|
|
612
|
+
# Arrange
|
|
613
|
+
types_and_counts = {
|
|
614
|
+
'skill_invocation': 2,
|
|
615
|
+
'subagent_invocation': 3,
|
|
616
|
+
'command_execution': 1,
|
|
617
|
+
'context_loading': 2,
|
|
618
|
+
}
|
|
619
|
+
tokens_per_skip = 1500
|
|
620
|
+
|
|
621
|
+
# Act - Create skips for each type
|
|
622
|
+
for op_type, count in types_and_counts.items():
|
|
623
|
+
for i in range(count):
|
|
624
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
625
|
+
|
|
626
|
+
# Calculate total waste
|
|
627
|
+
total_waste = 0
|
|
628
|
+
for op_type, expected_count in types_and_counts.items():
|
|
629
|
+
count = get_skip_count(op_type, config_dir=temp_config_dir)
|
|
630
|
+
assert count == expected_count
|
|
631
|
+
total_waste += count * tokens_per_skip
|
|
632
|
+
|
|
633
|
+
# Assert
|
|
634
|
+
expected_total = sum(counts * tokens_per_skip for counts in types_and_counts.values())
|
|
635
|
+
assert total_waste == expected_total
|
|
636
|
+
assert total_waste == 12000 # (2+3+1+2) * 1500 = 8 * 1500
|
|
637
|
+
|
|
638
|
+
def test_token_waste_in_user_question_context(self, temp_config_dir):
|
|
639
|
+
"""
|
|
640
|
+
GIVEN token waste calculated
|
|
641
|
+
WHEN presenting in AskUserQuestion
|
|
642
|
+
THEN include in context for user awareness
|
|
643
|
+
"""
|
|
644
|
+
# Arrange
|
|
645
|
+
user_id = 'test_user'
|
|
646
|
+
for i in range(3):
|
|
647
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
648
|
+
|
|
649
|
+
skip_count = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
650
|
+
token_waste = skip_count * 1500
|
|
651
|
+
|
|
652
|
+
# Act - Build AskUserQuestion context
|
|
653
|
+
question_context = {
|
|
654
|
+
'user_id': user_id,
|
|
655
|
+
'skip_count': skip_count,
|
|
656
|
+
'token_waste': token_waste,
|
|
657
|
+
'question': 'Would you like to continue disabling feedback?',
|
|
658
|
+
'options': [
|
|
659
|
+
'Keep feedback disabled',
|
|
660
|
+
'Re-enable feedback',
|
|
661
|
+
'Ask me later',
|
|
662
|
+
],
|
|
663
|
+
'metadata': {
|
|
664
|
+
'tokens_wasted': token_waste,
|
|
665
|
+
'tokens_per_skip': 1500,
|
|
666
|
+
'note': f'You have wasted {token_waste} tokens by skipping feedback {skip_count} times',
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
# Assert - Context includes token waste
|
|
671
|
+
assert 'token_waste' in question_context
|
|
672
|
+
assert question_context['token_waste'] == 4500
|
|
673
|
+
assert 'tokens_wasted' in question_context['metadata']
|
|
674
|
+
|
|
675
|
+
def test_token_waste_display_formatting(self, temp_config_dir):
|
|
676
|
+
"""
|
|
677
|
+
GIVEN token waste calculation
|
|
678
|
+
WHEN formatting for display
|
|
679
|
+
THEN show in human-readable format
|
|
680
|
+
"""
|
|
681
|
+
# Arrange
|
|
682
|
+
skip_counts = [1, 3, 5, 10]
|
|
683
|
+
tokens_per_skip = 1500
|
|
684
|
+
|
|
685
|
+
# Act - Format for display
|
|
686
|
+
for skip_count in skip_counts:
|
|
687
|
+
waste = skip_count * tokens_per_skip
|
|
688
|
+
|
|
689
|
+
# Format display
|
|
690
|
+
if waste >= 1000000:
|
|
691
|
+
display = f"{waste / 1000000:.1f}M tokens"
|
|
692
|
+
elif waste >= 1000:
|
|
693
|
+
display = f"{waste / 1000:.1f}K tokens"
|
|
694
|
+
else:
|
|
695
|
+
display = f"{waste} tokens"
|
|
696
|
+
|
|
697
|
+
# Assert - Formatted correctly
|
|
698
|
+
if skip_count == 1:
|
|
699
|
+
assert display == "1.5K tokens"
|
|
700
|
+
elif skip_count == 3:
|
|
701
|
+
assert display == "4.5K tokens"
|
|
702
|
+
elif skip_count == 5:
|
|
703
|
+
assert display == "7.5K tokens"
|
|
704
|
+
elif skip_count == 10:
|
|
705
|
+
assert display == "15.0K tokens"
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
class TestSessionPersistenceWorkflows:
|
|
709
|
+
"""
|
|
710
|
+
SCENARIO 6: Session Persistence Workflows
|
|
711
|
+
|
|
712
|
+
Session 1: Skip 2 times for skill_invocation
|
|
713
|
+
Session 2: Skip 1 more time → pattern detection (total 3)
|
|
714
|
+
Cross-session counter maintained
|
|
715
|
+
Pattern detection only once per session
|
|
716
|
+
"""
|
|
717
|
+
|
|
718
|
+
@pytest.fixture
|
|
719
|
+
def temp_config_dir(self):
|
|
720
|
+
"""Create temporary config directory"""
|
|
721
|
+
temp_dir = tempfile.mkdtemp()
|
|
722
|
+
yield Path(temp_dir)
|
|
723
|
+
shutil.rmtree(temp_dir)
|
|
724
|
+
|
|
725
|
+
def test_skip_count_persists_across_sessions(self, temp_config_dir):
|
|
726
|
+
"""
|
|
727
|
+
GIVEN skip count of 2 at end of session 1
|
|
728
|
+
WHEN session 2 starts
|
|
729
|
+
THEN skip count restored from config
|
|
730
|
+
"""
|
|
731
|
+
# Session 1
|
|
732
|
+
user_id = 'test_user'
|
|
733
|
+
op_type = 'skill_invocation'
|
|
734
|
+
|
|
735
|
+
count1 = increment_skip(op_type, config_dir=temp_config_dir)
|
|
736
|
+
count2 = increment_skip(op_type, config_dir=temp_config_dir)
|
|
737
|
+
|
|
738
|
+
assert count1 == 1
|
|
739
|
+
assert count2 == 2
|
|
740
|
+
|
|
741
|
+
# End session 1, start session 2
|
|
742
|
+
# (Config file persists on disk)
|
|
743
|
+
|
|
744
|
+
# Session 2
|
|
745
|
+
count3 = increment_skip(op_type, config_dir=temp_config_dir)
|
|
746
|
+
|
|
747
|
+
assert count3 == 3
|
|
748
|
+
|
|
749
|
+
# Verify persistent
|
|
750
|
+
persisted_count = get_skip_count(op_type, config_dir=temp_config_dir)
|
|
751
|
+
assert persisted_count == 3
|
|
752
|
+
|
|
753
|
+
def test_pattern_detection_fires_when_reaching_threshold(self, temp_config_dir):
|
|
754
|
+
"""
|
|
755
|
+
GIVEN session 1 skips 2 times (below threshold)
|
|
756
|
+
WHEN session 2 skips 1 more time (reaches threshold)
|
|
757
|
+
THEN pattern detection triggers in session 2
|
|
758
|
+
"""
|
|
759
|
+
# Arrange
|
|
760
|
+
user_id = 'test_user'
|
|
761
|
+
op_type = 'skill_invocation'
|
|
762
|
+
threshold = 3
|
|
763
|
+
|
|
764
|
+
# Session 1: Skip 2 times
|
|
765
|
+
for i in range(2):
|
|
766
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
767
|
+
|
|
768
|
+
# Check: Below threshold
|
|
769
|
+
assert check_skip_threshold(op_type, threshold=threshold, config_dir=temp_config_dir) is False
|
|
770
|
+
|
|
771
|
+
# Session 2: Skip 1 more time
|
|
772
|
+
count = increment_skip(op_type, config_dir=temp_config_dir)
|
|
773
|
+
assert count == 3
|
|
774
|
+
|
|
775
|
+
# Check: Threshold reached
|
|
776
|
+
assert check_skip_threshold(op_type, threshold=threshold, config_dir=temp_config_dir) is True
|
|
777
|
+
|
|
778
|
+
def test_pattern_detection_only_once_per_session(self, temp_config_dir):
|
|
779
|
+
"""
|
|
780
|
+
GIVEN pattern detection fires when reaching threshold
|
|
781
|
+
WHEN checking multiple times in same session
|
|
782
|
+
THEN pattern detection state doesn't change
|
|
783
|
+
"""
|
|
784
|
+
# Arrange
|
|
785
|
+
op_type = 'skill_invocation'
|
|
786
|
+
threshold = 3
|
|
787
|
+
|
|
788
|
+
# Build up to threshold
|
|
789
|
+
for i in range(3):
|
|
790
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
791
|
+
|
|
792
|
+
# Act - Check pattern detection multiple times in same session
|
|
793
|
+
result1 = check_skip_threshold(op_type, threshold=threshold, config_dir=temp_config_dir)
|
|
794
|
+
result2 = check_skip_threshold(op_type, threshold=threshold, config_dir=temp_config_dir)
|
|
795
|
+
result3 = check_skip_threshold(op_type, threshold=threshold, config_dir=temp_config_dir)
|
|
796
|
+
|
|
797
|
+
# Assert - All same
|
|
798
|
+
assert result1 is True
|
|
799
|
+
assert result2 is True
|
|
800
|
+
assert result3 is True
|
|
801
|
+
|
|
802
|
+
# Verify skip count hasn't changed
|
|
803
|
+
count = get_skip_count(op_type, config_dir=temp_config_dir)
|
|
804
|
+
assert count == 3
|
|
805
|
+
|
|
806
|
+
def test_different_operation_types_cross_session(self, temp_config_dir):
|
|
807
|
+
"""
|
|
808
|
+
GIVEN session 1 tracks multiple operation types
|
|
809
|
+
WHEN session 2 starts
|
|
810
|
+
THEN all operation type counters maintained independently
|
|
811
|
+
"""
|
|
812
|
+
# Session 1
|
|
813
|
+
ops = {
|
|
814
|
+
'skill_invocation': 2,
|
|
815
|
+
'subagent_invocation': 1,
|
|
816
|
+
'command_execution': 3,
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
for op_type, count in ops.items():
|
|
820
|
+
for i in range(count):
|
|
821
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
822
|
+
|
|
823
|
+
# Session 2: Verify all persisted
|
|
824
|
+
for op_type, expected_count in ops.items():
|
|
825
|
+
actual_count = get_skip_count(op_type, config_dir=temp_config_dir)
|
|
826
|
+
assert actual_count == expected_count
|
|
827
|
+
|
|
828
|
+
def test_session_timestamp_tracking(self, temp_config_dir):
|
|
829
|
+
"""
|
|
830
|
+
GIVEN session persistence needed
|
|
831
|
+
WHEN tracking sessions
|
|
832
|
+
THEN record session timestamps
|
|
833
|
+
"""
|
|
834
|
+
# Arrange
|
|
835
|
+
op_type = 'skill_invocation'
|
|
836
|
+
|
|
837
|
+
# Session 1
|
|
838
|
+
session1_time = datetime.now(UTC).isoformat()
|
|
839
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
840
|
+
|
|
841
|
+
# Add session metadata
|
|
842
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
843
|
+
with open(config_file, 'r') as f:
|
|
844
|
+
config = yaml.safe_load(f)
|
|
845
|
+
|
|
846
|
+
if 'sessions' not in config:
|
|
847
|
+
config['sessions'] = []
|
|
848
|
+
|
|
849
|
+
config['sessions'].append({
|
|
850
|
+
'session_id': 'session_1',
|
|
851
|
+
'start_time': session1_time,
|
|
852
|
+
'operation_type': op_type,
|
|
853
|
+
})
|
|
854
|
+
|
|
855
|
+
with open(config_file, 'w') as f:
|
|
856
|
+
yaml.safe_dump(config, f)
|
|
857
|
+
|
|
858
|
+
# Session 2
|
|
859
|
+
session2_time = datetime.now(UTC).isoformat()
|
|
860
|
+
increment_skip(op_type, config_dir=temp_config_dir)
|
|
861
|
+
|
|
862
|
+
# Update session metadata
|
|
863
|
+
with open(config_file, 'r') as f:
|
|
864
|
+
config = yaml.safe_load(f)
|
|
865
|
+
|
|
866
|
+
config['sessions'].append({
|
|
867
|
+
'session_id': 'session_2',
|
|
868
|
+
'start_time': session2_time,
|
|
869
|
+
'operation_type': op_type,
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
with open(config_file, 'w') as f:
|
|
873
|
+
yaml.safe_dump(config, f)
|
|
874
|
+
|
|
875
|
+
# Assert - Sessions tracked
|
|
876
|
+
with open(config_file, 'r') as f:
|
|
877
|
+
config = yaml.safe_load(f)
|
|
878
|
+
|
|
879
|
+
assert len(config['sessions']) == 2
|
|
880
|
+
assert config['sessions'][0]['session_id'] == 'session_1'
|
|
881
|
+
assert config['sessions'][1]['session_id'] == 'session_2'
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
class TestErrorRecoveryIntegration:
|
|
885
|
+
"""
|
|
886
|
+
SCENARIO 7: Error Recovery Integration
|
|
887
|
+
|
|
888
|
+
Corrupted config doesn't crash feedback system
|
|
889
|
+
Backup created automatically
|
|
890
|
+
Fresh config generated
|
|
891
|
+
User operations continue without blocking
|
|
892
|
+
"""
|
|
893
|
+
|
|
894
|
+
@pytest.fixture
|
|
895
|
+
def temp_config_dir(self):
|
|
896
|
+
"""Create temporary config directory"""
|
|
897
|
+
temp_dir = tempfile.mkdtemp()
|
|
898
|
+
yield Path(temp_dir)
|
|
899
|
+
shutil.rmtree(temp_dir)
|
|
900
|
+
|
|
901
|
+
def test_corrupted_config_raises_error_appropriately(self, temp_config_dir):
|
|
902
|
+
"""
|
|
903
|
+
GIVEN config file is corrupted
|
|
904
|
+
WHEN skip tracking invoked
|
|
905
|
+
THEN appropriate YAML error is raised
|
|
906
|
+
|
|
907
|
+
Note: Current implementation doesn't silently recover from corruption.
|
|
908
|
+
This is appropriate behavior as it prevents silent data loss.
|
|
909
|
+
Future enhancement: could add backup/recovery mechanism if desired.
|
|
910
|
+
"""
|
|
911
|
+
# Arrange - Corrupt config
|
|
912
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
913
|
+
config_file.write_text("invalid: [yaml: {content")
|
|
914
|
+
|
|
915
|
+
# Act - Try to use skip tracking
|
|
916
|
+
user_id = 'test_user'
|
|
917
|
+
|
|
918
|
+
# Assert - Error is raised appropriately
|
|
919
|
+
with pytest.raises(yaml.YAMLError):
|
|
920
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
921
|
+
|
|
922
|
+
def test_corrupted_config_error_message_helpful(self, temp_config_dir):
|
|
923
|
+
"""
|
|
924
|
+
GIVEN config corruption detected
|
|
925
|
+
WHEN error raised
|
|
926
|
+
THEN error message is helpful for user
|
|
927
|
+
|
|
928
|
+
Current implementation raises YAML errors which indicate the problem.
|
|
929
|
+
"""
|
|
930
|
+
# Arrange
|
|
931
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
932
|
+
corrupt_content = "bad: yaml: [content"
|
|
933
|
+
config_file.write_text(corrupt_content)
|
|
934
|
+
|
|
935
|
+
# Act & Assert
|
|
936
|
+
user_id = 'test_user'
|
|
937
|
+
try:
|
|
938
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
939
|
+
pytest.fail("Expected YAML error")
|
|
940
|
+
except yaml.YAMLError as e:
|
|
941
|
+
# Error is informative about what's wrong
|
|
942
|
+
assert isinstance(e, yaml.YAMLError)
|
|
943
|
+
# Message indicates YAML parsing issue
|
|
944
|
+
error_str = str(e).lower()
|
|
945
|
+
# Should mention YAML concepts that help user understand issue
|
|
946
|
+
is_helpful = any(word in error_str for word in
|
|
947
|
+
['parsing', 'flow', 'expected', 'block', 'mapping',
|
|
948
|
+
'while parsing', 'scanner', 'parser'])
|
|
949
|
+
assert is_helpful, f"Error message not helpful: {e}"
|
|
950
|
+
|
|
951
|
+
def test_delete_corrupted_config_allows_fresh_start(self, temp_config_dir):
|
|
952
|
+
"""
|
|
953
|
+
GIVEN corrupted config exists
|
|
954
|
+
WHEN config file is deleted
|
|
955
|
+
THEN fresh config generated on next operation
|
|
956
|
+
"""
|
|
957
|
+
# Arrange
|
|
958
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
959
|
+
config_file.write_text("corrupted content here")
|
|
960
|
+
|
|
961
|
+
# Act - User manually deletes corrupted file
|
|
962
|
+
config_file.unlink()
|
|
963
|
+
|
|
964
|
+
user_id = 'test_user'
|
|
965
|
+
count = increment_skip(user_id, config_dir=temp_config_dir)
|
|
966
|
+
|
|
967
|
+
# Assert - Fresh config created
|
|
968
|
+
assert count == 1
|
|
969
|
+
|
|
970
|
+
with open(config_file, 'r') as f:
|
|
971
|
+
config = yaml.safe_load(f)
|
|
972
|
+
|
|
973
|
+
assert 'skip_counts' in config
|
|
974
|
+
assert isinstance(config['skip_counts'], dict)
|
|
975
|
+
assert user_id in config['skip_counts']
|
|
976
|
+
assert config['skip_counts'][user_id] == 1
|
|
977
|
+
|
|
978
|
+
def test_valid_config_creation_from_scratch(self, temp_config_dir):
|
|
979
|
+
"""
|
|
980
|
+
GIVEN no config file exists
|
|
981
|
+
WHEN skip tracking invoked
|
|
982
|
+
THEN fresh config created with correct structure
|
|
983
|
+
"""
|
|
984
|
+
# Arrange
|
|
985
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
986
|
+
assert not config_file.exists()
|
|
987
|
+
|
|
988
|
+
# Act
|
|
989
|
+
user_id = 'test_user'
|
|
990
|
+
count1 = increment_skip(user_id, config_dir=temp_config_dir)
|
|
991
|
+
count2 = increment_skip(user_id, config_dir=temp_config_dir)
|
|
992
|
+
|
|
993
|
+
# Assert - Config created with correct structure
|
|
994
|
+
assert config_file.exists()
|
|
995
|
+
|
|
996
|
+
with open(config_file, 'r') as f:
|
|
997
|
+
config = yaml.safe_load(f)
|
|
998
|
+
|
|
999
|
+
assert config is not None
|
|
1000
|
+
assert 'skip_counts' in config
|
|
1001
|
+
assert isinstance(config['skip_counts'], dict)
|
|
1002
|
+
assert config['skip_counts'][user_id] == 2
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
class TestMultiComponentWorkflows:
|
|
1006
|
+
"""
|
|
1007
|
+
SCENARIO 8: Multi-Component Workflows
|
|
1008
|
+
|
|
1009
|
+
implementing-stories skill calls feedback system
|
|
1010
|
+
feedback system calls skip_tracking
|
|
1011
|
+
skip_tracking detects pattern
|
|
1012
|
+
AskUserQuestion presented to user
|
|
1013
|
+
User preference saved
|
|
1014
|
+
Subsequent operations respect preference
|
|
1015
|
+
"""
|
|
1016
|
+
|
|
1017
|
+
@pytest.fixture
|
|
1018
|
+
def temp_config_dir(self):
|
|
1019
|
+
"""Create temporary config directory"""
|
|
1020
|
+
temp_dir = tempfile.mkdtemp()
|
|
1021
|
+
yield Path(temp_dir)
|
|
1022
|
+
shutil.rmtree(temp_dir)
|
|
1023
|
+
|
|
1024
|
+
@pytest.fixture
|
|
1025
|
+
def sample_question_bank(self):
|
|
1026
|
+
"""Sample question bank"""
|
|
1027
|
+
return {
|
|
1028
|
+
'dev': {
|
|
1029
|
+
'passed': [
|
|
1030
|
+
{'id': 'dev_pass_1', 'text': 'Confident?', 'priority': 1, 'success_status': 'passed'},
|
|
1031
|
+
],
|
|
1032
|
+
},
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
def test_skill_to_feedback_to_skip_tracking_workflow(self, temp_config_dir, sample_question_bank):
|
|
1036
|
+
"""
|
|
1037
|
+
GIVEN implementing-stories skill completes
|
|
1038
|
+
WHEN feedback triggered
|
|
1039
|
+
THEN skip_tracking invoked with pattern detection
|
|
1040
|
+
"""
|
|
1041
|
+
# Arrange
|
|
1042
|
+
operation_type = 'dev'
|
|
1043
|
+
user_id = 'test_user'
|
|
1044
|
+
|
|
1045
|
+
# Simulate skill → feedback → skip_tracking call chain
|
|
1046
|
+
|
|
1047
|
+
# Step 1: Skill calls feedback system (trigger_retrospective)
|
|
1048
|
+
# Step 2: User skips feedback 3 times
|
|
1049
|
+
for i in range(3):
|
|
1050
|
+
count = increment_skip(user_id, config_dir=temp_config_dir)
|
|
1051
|
+
|
|
1052
|
+
# Step 3: Pattern detected
|
|
1053
|
+
pattern_detected = check_skip_threshold(user_id, threshold=3, config_dir=temp_config_dir)
|
|
1054
|
+
assert pattern_detected is True
|
|
1055
|
+
|
|
1056
|
+
# Step 4: AskUserQuestion presented
|
|
1057
|
+
ask_user_context = {
|
|
1058
|
+
'user_id': user_id,
|
|
1059
|
+
'skip_count': 3,
|
|
1060
|
+
'threshold': 3,
|
|
1061
|
+
'question': 'Continue disabling feedback?',
|
|
1062
|
+
'options': ['Yes', 'No', 'Ask later'],
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
# Assert - Workflow complete
|
|
1066
|
+
assert ask_user_context['skip_count'] == 3
|
|
1067
|
+
|
|
1068
|
+
def test_user_preference_persisted_across_components(self, temp_config_dir, sample_question_bank):
|
|
1069
|
+
"""
|
|
1070
|
+
GIVEN user preference set in feedback system
|
|
1071
|
+
WHEN subsequent operations occur
|
|
1072
|
+
THEN all components respect preference
|
|
1073
|
+
"""
|
|
1074
|
+
# Arrange
|
|
1075
|
+
user_id = 'test_user'
|
|
1076
|
+
|
|
1077
|
+
# Build skip count to threshold
|
|
1078
|
+
for i in range(3):
|
|
1079
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
1080
|
+
|
|
1081
|
+
# User chooses preference
|
|
1082
|
+
reset_skip_count(user_id, config_dir=temp_config_dir)
|
|
1083
|
+
|
|
1084
|
+
# Save preference
|
|
1085
|
+
config_file = temp_config_dir / 'feedback-preferences.yaml'
|
|
1086
|
+
with open(config_file, 'r') as f:
|
|
1087
|
+
config = yaml.safe_load(f)
|
|
1088
|
+
|
|
1089
|
+
if 'user_preferences' not in config:
|
|
1090
|
+
config['user_preferences'] = {}
|
|
1091
|
+
|
|
1092
|
+
config['user_preferences'][user_id] = {
|
|
1093
|
+
'feedback_enabled': False,
|
|
1094
|
+
'set_at': datetime.now(UTC).isoformat(),
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
with open(config_file, 'w') as f:
|
|
1098
|
+
yaml.safe_dump(config, f)
|
|
1099
|
+
|
|
1100
|
+
# Act - Simulate subsequent operations
|
|
1101
|
+
count = increment_skip(user_id, config_dir=temp_config_dir)
|
|
1102
|
+
|
|
1103
|
+
# Assert
|
|
1104
|
+
assert count == 1 # Started fresh after reset
|
|
1105
|
+
|
|
1106
|
+
# Preference persisted
|
|
1107
|
+
with open(config_file, 'r') as f:
|
|
1108
|
+
config = yaml.safe_load(f)
|
|
1109
|
+
|
|
1110
|
+
assert user_id in config['user_preferences']
|
|
1111
|
+
assert config['user_preferences'][user_id]['feedback_enabled'] is False
|
|
1112
|
+
|
|
1113
|
+
def test_adaptive_engine_respects_skip_pattern(self, temp_config_dir, sample_question_bank):
|
|
1114
|
+
"""
|
|
1115
|
+
GIVEN skip pattern detected
|
|
1116
|
+
WHEN adaptive engine selects questions
|
|
1117
|
+
THEN engine acknowledges skip pattern in context
|
|
1118
|
+
"""
|
|
1119
|
+
# Arrange
|
|
1120
|
+
user_id = 'test_user'
|
|
1121
|
+
engine = AdaptiveQuestioningEngine(sample_question_bank)
|
|
1122
|
+
|
|
1123
|
+
# Build skip pattern
|
|
1124
|
+
for i in range(3):
|
|
1125
|
+
increment_skip(user_id, config_dir=temp_config_dir)
|
|
1126
|
+
|
|
1127
|
+
skip_count = get_skip_count(user_id, config_dir=temp_config_dir)
|
|
1128
|
+
|
|
1129
|
+
# Act - Engine selects questions with skip context
|
|
1130
|
+
context = {
|
|
1131
|
+
'operation_type': 'dev',
|
|
1132
|
+
'success_status': 'passed',
|
|
1133
|
+
'user_id': user_id,
|
|
1134
|
+
'timestamp': datetime.now(UTC).isoformat(),
|
|
1135
|
+
'operation_history': [],
|
|
1136
|
+
'question_history': [],
|
|
1137
|
+
'skip_pattern': {
|
|
1138
|
+
'skip_count': skip_count,
|
|
1139
|
+
'threshold_reached': True,
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
result = engine.select_questions(context)
|
|
1144
|
+
|
|
1145
|
+
# Assert - Engine selected questions
|
|
1146
|
+
# With minimal question bank, may only have 1 question, which is still valid
|
|
1147
|
+
assert result['total_selected'] >= 1
|
|
1148
|
+
# Questions selected
|
|
1149
|
+
assert len(result['selected_questions']) > 0
|
|
1150
|
+
# Skip pattern is acknowledged in context
|
|
1151
|
+
assert 'skip_pattern' in context
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
if __name__ == '__main__':
|
|
1155
|
+
pytest.main([__file__, '-v'])
|