ara-cli 0.1.10.0__py3-none-any.whl → 0.1.13.3__py3-none-any.whl
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.
- ara_cli/__init__.py +51 -6
- ara_cli/__main__.py +270 -103
- ara_cli/ara_command_action.py +106 -63
- ara_cli/ara_config.py +187 -128
- ara_cli/ara_subcommands/__init__.py +0 -0
- ara_cli/ara_subcommands/autofix.py +26 -0
- ara_cli/ara_subcommands/chat.py +27 -0
- ara_cli/ara_subcommands/classifier_directory.py +16 -0
- ara_cli/ara_subcommands/common.py +100 -0
- ara_cli/ara_subcommands/config.py +221 -0
- ara_cli/ara_subcommands/convert.py +43 -0
- ara_cli/ara_subcommands/create.py +75 -0
- ara_cli/ara_subcommands/delete.py +22 -0
- ara_cli/ara_subcommands/extract.py +22 -0
- ara_cli/ara_subcommands/fetch.py +41 -0
- ara_cli/ara_subcommands/fetch_agents.py +22 -0
- ara_cli/ara_subcommands/fetch_scripts.py +19 -0
- ara_cli/ara_subcommands/fetch_templates.py +19 -0
- ara_cli/ara_subcommands/list.py +139 -0
- ara_cli/ara_subcommands/list_tags.py +25 -0
- ara_cli/ara_subcommands/load.py +48 -0
- ara_cli/ara_subcommands/prompt.py +136 -0
- ara_cli/ara_subcommands/read.py +47 -0
- ara_cli/ara_subcommands/read_status.py +20 -0
- ara_cli/ara_subcommands/read_user.py +20 -0
- ara_cli/ara_subcommands/reconnect.py +27 -0
- ara_cli/ara_subcommands/rename.py +22 -0
- ara_cli/ara_subcommands/scan.py +14 -0
- ara_cli/ara_subcommands/set_status.py +22 -0
- ara_cli/ara_subcommands/set_user.py +22 -0
- ara_cli/ara_subcommands/template.py +16 -0
- ara_cli/artefact_autofix.py +154 -63
- ara_cli/artefact_converter.py +256 -0
- ara_cli/artefact_models/artefact_model.py +106 -25
- ara_cli/artefact_models/artefact_templates.py +20 -10
- ara_cli/artefact_models/epic_artefact_model.py +11 -2
- ara_cli/artefact_models/feature_artefact_model.py +31 -1
- ara_cli/artefact_models/userstory_artefact_model.py +15 -3
- ara_cli/artefact_scan.py +2 -2
- ara_cli/chat.py +283 -80
- ara_cli/chat_agent/__init__.py +0 -0
- ara_cli/chat_agent/agent_process_manager.py +155 -0
- ara_cli/chat_script_runner/__init__.py +0 -0
- ara_cli/chat_script_runner/script_completer.py +23 -0
- ara_cli/chat_script_runner/script_finder.py +41 -0
- ara_cli/chat_script_runner/script_lister.py +36 -0
- ara_cli/chat_script_runner/script_runner.py +36 -0
- ara_cli/chat_web_search/__init__.py +0 -0
- ara_cli/chat_web_search/web_search.py +263 -0
- ara_cli/commands/agent_run_command.py +98 -0
- ara_cli/commands/fetch_agents_command.py +106 -0
- ara_cli/commands/fetch_scripts_command.py +43 -0
- ara_cli/commands/fetch_templates_command.py +39 -0
- ara_cli/commands/fetch_templates_commands.py +39 -0
- ara_cli/commands/list_agents_command.py +39 -0
- ara_cli/commands/read_command.py +17 -4
- ara_cli/completers.py +180 -0
- ara_cli/constants.py +2 -0
- ara_cli/directory_navigator.py +37 -4
- ara_cli/file_loaders/text_file_loader.py +2 -2
- ara_cli/global_file_lister.py +5 -15
- ara_cli/llm_utils.py +58 -0
- ara_cli/prompt_chat.py +20 -4
- ara_cli/prompt_extractor.py +199 -76
- ara_cli/prompt_handler.py +160 -59
- ara_cli/tag_extractor.py +38 -18
- ara_cli/template_loader.py +3 -2
- ara_cli/template_manager.py +52 -21
- ara_cli/templates/global-scripts/hello_global.py +1 -0
- ara_cli/templates/prompt-modules/commands/add_scenarios_for_new_behaviour.feature_creation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/align_feature_with_implementation_changes.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/analyze_codebase_and_plan_tasks.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/choose_best_parent_artefact.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/create_tasks_from_artefact_content.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/create_tests_for_uncovered_modules.test_generation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/derive_features_from_video_description.feature_creation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/describe_agent_capabilities.agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/empty.commands.md +2 -12
- ara_cli/templates/prompt-modules/commands/execute_scoped_todos_in_task.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/explain_single_file_purpose.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/extract_file_information_bullets.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/extract_general.commands.md +12 -0
- ara_cli/templates/prompt-modules/commands/extract_markdown.commands.md +11 -0
- ara_cli/templates/prompt-modules/commands/extract_python.commands.md +13 -0
- ara_cli/templates/prompt-modules/commands/feature_add_or_modifiy_specified_behavior.commands.md +36 -0
- ara_cli/templates/prompt-modules/commands/feature_generate_initial_specified_bevahior.commands.md +53 -0
- ara_cli/templates/prompt-modules/commands/fix_failing_behave_step_definitions.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/fix_failing_pytest_tests.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/general_instruction_policy.commands.md +47 -0
- ara_cli/templates/prompt-modules/commands/generate_and_fix_pytest_tests.test_generation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/prompt_template_tech_stack_transformer.commands.md +95 -0
- ara_cli/templates/prompt-modules/commands/python_bug_fixing_code.commands.md +34 -0
- ara_cli/templates/prompt-modules/commands/python_generate_code.commands.md +27 -0
- ara_cli/templates/prompt-modules/commands/python_refactoring_code.commands.md +39 -0
- ara_cli/templates/prompt-modules/commands/python_step_definitions_generation_and_fixing.commands.md +40 -0
- ara_cli/templates/prompt-modules/commands/python_unittest_generation_and_fixing.commands.md +48 -0
- ara_cli/templates/prompt-modules/commands/suggest_next_story_child_tasks.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/summarize_or_transcribe_media.interview_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/update_feature_to_match_implementation.feature_creation_agent.commands.md +1 -0
- ara_cli/templates/prompt-modules/commands/update_user_story_with_requirements.interview_agent.commands.md +1 -0
- ara_cli/version.py +1 -1
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/METADATA +34 -1
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/RECORD +123 -54
- tests/test_ara_command_action.py +31 -19
- tests/test_ara_config.py +177 -90
- tests/test_artefact_autofix.py +170 -97
- tests/test_artefact_autofix_integration.py +495 -0
- tests/test_artefact_converter.py +357 -0
- tests/test_artefact_extraction.py +564 -0
- tests/test_artefact_scan.py +1 -1
- tests/test_chat.py +162 -126
- tests/test_chat_givens_images.py +603 -0
- tests/test_chat_script_runner.py +454 -0
- tests/test_global_file_lister.py +1 -1
- tests/test_llm_utils.py +164 -0
- tests/test_prompt_chat.py +343 -0
- tests/test_prompt_extractor.py +683 -0
- tests/test_prompt_handler.py +12 -4
- tests/test_tag_extractor.py +19 -13
- tests/test_web_search.py +467 -0
- ara_cli/ara_command_parser.py +0 -605
- ara_cli/templates/prompt-modules/blueprints/complete_pytest_unittest.blueprint.md +0 -27
- ara_cli/templates/prompt-modules/blueprints/task_todo_list_implement_feature_BDD_way.blueprint.md +0 -30
- ara_cli/templates/prompt-modules/commands/artefact_classification.commands.md +0 -9
- ara_cli/templates/prompt-modules/commands/artefact_extension.commands.md +0 -17
- ara_cli/templates/prompt-modules/commands/artefact_formulation.commands.md +0 -14
- ara_cli/templates/prompt-modules/commands/behave_step_generation.commands.md +0 -102
- ara_cli/templates/prompt-modules/commands/code_generation_complex.commands.md +0 -20
- ara_cli/templates/prompt-modules/commands/code_generation_simple.commands.md +0 -13
- ara_cli/templates/prompt-modules/commands/error_fixing.commands.md +0 -20
- ara_cli/templates/prompt-modules/commands/feature_file_update.commands.md +0 -18
- ara_cli/templates/prompt-modules/commands/feature_formulation.commands.md +0 -43
- ara_cli/templates/prompt-modules/commands/js_code_generation_simple.commands.md +0 -13
- ara_cli/templates/prompt-modules/commands/refactoring.commands.md +0 -15
- ara_cli/templates/prompt-modules/commands/refactoring_analysis.commands.md +0 -9
- ara_cli/templates/prompt-modules/commands/reverse_engineer_feature_file.commands.md +0 -15
- ara_cli/templates/prompt-modules/commands/reverse_engineer_program_flow.commands.md +0 -19
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/WHEEL +0 -0
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/entry_points.txt +0 -0
- {ara_cli-0.1.10.0.dist-info → ara_cli-0.1.13.3.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for prompt_extractor.py
|
|
3
|
+
|
|
4
|
+
These tests cover the functionality previously tested by:
|
|
5
|
+
- agile_artefact_extraction.feature
|
|
6
|
+
- agile_artefact_extraction_force.feature
|
|
7
|
+
- agile_artefact_extraction_override.feature
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
from unittest.mock import patch, MagicMock, mock_open
|
|
14
|
+
from ara_cli.prompt_extractor import (
|
|
15
|
+
_find_extract_token,
|
|
16
|
+
_extract_file_path,
|
|
17
|
+
_find_artefact_class,
|
|
18
|
+
_process_file_extraction,
|
|
19
|
+
_process_artefact_extraction,
|
|
20
|
+
_perform_extraction_for_block,
|
|
21
|
+
FenceDetector,
|
|
22
|
+
_process_document_blocks,
|
|
23
|
+
_apply_replacements,
|
|
24
|
+
_setup_working_directory,
|
|
25
|
+
extract_responses,
|
|
26
|
+
modify_and_save_file,
|
|
27
|
+
prompt_user_decision,
|
|
28
|
+
determine_should_create,
|
|
29
|
+
create_file_if_not_exist,
|
|
30
|
+
create_prompt_for_file_modification,
|
|
31
|
+
handle_existing_file,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# Tests for _find_extract_token
|
|
37
|
+
# =============================================================================
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TestFindExtractToken:
|
|
41
|
+
"""Tests for _find_extract_token function."""
|
|
42
|
+
|
|
43
|
+
def test_returns_token_when_extract_marker_found(self):
|
|
44
|
+
"""Token with extract marker is returned."""
|
|
45
|
+
mock_token = MagicMock()
|
|
46
|
+
mock_token.type = "fence"
|
|
47
|
+
mock_token.content = "# [x] extract\n@creator_unknown\nFeature: sample"
|
|
48
|
+
|
|
49
|
+
result = _find_extract_token([mock_token])
|
|
50
|
+
|
|
51
|
+
assert result == mock_token
|
|
52
|
+
|
|
53
|
+
def test_returns_none_when_no_extract_marker(self):
|
|
54
|
+
"""None returned when no extract marker present."""
|
|
55
|
+
mock_token = MagicMock()
|
|
56
|
+
mock_token.type = "fence"
|
|
57
|
+
mock_token.content = "Regular code block content"
|
|
58
|
+
|
|
59
|
+
result = _find_extract_token([mock_token])
|
|
60
|
+
|
|
61
|
+
assert result is None
|
|
62
|
+
|
|
63
|
+
def test_returns_none_when_not_fence_type(self):
|
|
64
|
+
"""None returned when token is not a fence type."""
|
|
65
|
+
mock_token = MagicMock()
|
|
66
|
+
mock_token.type = "paragraph"
|
|
67
|
+
mock_token.content = "# [x] extract\nContent"
|
|
68
|
+
|
|
69
|
+
result = _find_extract_token([mock_token])
|
|
70
|
+
|
|
71
|
+
assert result is None
|
|
72
|
+
|
|
73
|
+
def test_returns_none_for_empty_tokens(self):
|
|
74
|
+
"""None returned for empty token list."""
|
|
75
|
+
result = _find_extract_token([])
|
|
76
|
+
assert result is None
|
|
77
|
+
|
|
78
|
+
def test_returns_first_matching_token(self):
|
|
79
|
+
"""First matching token is returned when multiple exist."""
|
|
80
|
+
token1 = MagicMock()
|
|
81
|
+
token1.type = "fence"
|
|
82
|
+
token1.content = "# [x] extract\nFirst"
|
|
83
|
+
|
|
84
|
+
token2 = MagicMock()
|
|
85
|
+
token2.type = "fence"
|
|
86
|
+
token2.content = "# [x] extract\nSecond"
|
|
87
|
+
|
|
88
|
+
result = _find_extract_token([token1, token2])
|
|
89
|
+
|
|
90
|
+
assert result == token1
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# =============================================================================
|
|
94
|
+
# Tests for _extract_file_path
|
|
95
|
+
# =============================================================================
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TestExtractFilePath:
|
|
99
|
+
"""Tests for _extract_file_path function."""
|
|
100
|
+
|
|
101
|
+
def test_extracts_filename_from_first_line(self):
|
|
102
|
+
"""Filename is extracted from the first line."""
|
|
103
|
+
content_lines = ["# filename: path/to/file.py", "other content"]
|
|
104
|
+
|
|
105
|
+
result = _extract_file_path(content_lines)
|
|
106
|
+
|
|
107
|
+
assert result == "path/to/file.py"
|
|
108
|
+
|
|
109
|
+
def test_returns_none_when_no_filename_marker(self):
|
|
110
|
+
"""None returned when no filename marker present."""
|
|
111
|
+
content_lines = ["@creator_unknown", "Feature: sample"]
|
|
112
|
+
|
|
113
|
+
result = _extract_file_path(content_lines)
|
|
114
|
+
|
|
115
|
+
assert result is None
|
|
116
|
+
|
|
117
|
+
def test_returns_none_for_empty_lines(self):
|
|
118
|
+
"""None returned for empty content lines."""
|
|
119
|
+
result = _extract_file_path([])
|
|
120
|
+
assert result is None
|
|
121
|
+
|
|
122
|
+
def test_strips_whitespace_from_filename(self):
|
|
123
|
+
"""Whitespace is stripped from the extracted filename."""
|
|
124
|
+
content_lines = ["# filename: path/to/file.py "]
|
|
125
|
+
|
|
126
|
+
result = _extract_file_path(content_lines)
|
|
127
|
+
|
|
128
|
+
assert result == "path/to/file.py"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# =============================================================================
|
|
132
|
+
# Tests for _find_artefact_class
|
|
133
|
+
# =============================================================================
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestFindArtefactClass:
|
|
137
|
+
"""Tests for _find_artefact_class function."""
|
|
138
|
+
|
|
139
|
+
@pytest.mark.parametrize(
|
|
140
|
+
"first_word,expected_not_none",
|
|
141
|
+
[
|
|
142
|
+
("Feature:", True),
|
|
143
|
+
("Task:", True),
|
|
144
|
+
("Epic:", True),
|
|
145
|
+
("Userstory:", True),
|
|
146
|
+
("Businessgoal:", True),
|
|
147
|
+
("Capability:", True),
|
|
148
|
+
("Keyfeature:", True),
|
|
149
|
+
("Vision:", True),
|
|
150
|
+
("Example:", True),
|
|
151
|
+
("Issue:", True),
|
|
152
|
+
],
|
|
153
|
+
)
|
|
154
|
+
def test_finds_artefact_class_for_known_prefixes(
|
|
155
|
+
self, first_word, expected_not_none
|
|
156
|
+
):
|
|
157
|
+
"""Artefact class found for known prefixes."""
|
|
158
|
+
content_lines = [f"{first_word} sample artefact"]
|
|
159
|
+
|
|
160
|
+
result = _find_artefact_class(content_lines)
|
|
161
|
+
|
|
162
|
+
if expected_not_none:
|
|
163
|
+
assert result is not None
|
|
164
|
+
else:
|
|
165
|
+
assert result is None
|
|
166
|
+
|
|
167
|
+
def test_returns_none_for_unknown_prefix(self):
|
|
168
|
+
"""None returned for unknown prefix."""
|
|
169
|
+
content_lines = ["Unknown: something"]
|
|
170
|
+
|
|
171
|
+
result = _find_artefact_class(content_lines)
|
|
172
|
+
|
|
173
|
+
assert result is None
|
|
174
|
+
|
|
175
|
+
def test_checks_first_two_lines_only(self):
|
|
176
|
+
"""Only first two lines are checked for artefact class."""
|
|
177
|
+
content_lines = ["@creator", "Feature: sample", "Task: ignored"]
|
|
178
|
+
|
|
179
|
+
result = _find_artefact_class(content_lines)
|
|
180
|
+
|
|
181
|
+
assert result is not None
|
|
182
|
+
|
|
183
|
+
def test_returns_none_for_empty_lines(self):
|
|
184
|
+
"""None returned for empty content lines."""
|
|
185
|
+
result = _find_artefact_class([])
|
|
186
|
+
assert result is None
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# =============================================================================
|
|
190
|
+
# Tests for FenceDetector
|
|
191
|
+
# =============================================================================
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class TestFenceDetector:
|
|
195
|
+
"""Tests for FenceDetector class."""
|
|
196
|
+
|
|
197
|
+
def test_is_extract_fence_returns_true_for_extract_block(self):
|
|
198
|
+
"""Returns True for fence followed by extract marker."""
|
|
199
|
+
source_lines = ["```", "# [x] extract", "content", "```"]
|
|
200
|
+
detector = FenceDetector(source_lines)
|
|
201
|
+
|
|
202
|
+
result = detector.is_extract_fence(0)
|
|
203
|
+
|
|
204
|
+
assert result is True
|
|
205
|
+
|
|
206
|
+
def test_is_extract_fence_returns_false_for_regular_fence(self):
|
|
207
|
+
"""Returns False for regular fence without extract marker."""
|
|
208
|
+
source_lines = ["```python", "code = 1", "```"]
|
|
209
|
+
detector = FenceDetector(source_lines)
|
|
210
|
+
|
|
211
|
+
result = detector.is_extract_fence(0)
|
|
212
|
+
|
|
213
|
+
assert result is False
|
|
214
|
+
|
|
215
|
+
def test_is_extract_fence_returns_false_for_non_fence(self):
|
|
216
|
+
"""Returns False for non-fence lines."""
|
|
217
|
+
source_lines = ["regular text", "more text"]
|
|
218
|
+
detector = FenceDetector(source_lines)
|
|
219
|
+
|
|
220
|
+
result = detector.is_extract_fence(0)
|
|
221
|
+
|
|
222
|
+
assert result is False
|
|
223
|
+
|
|
224
|
+
def test_is_extract_fence_works_with_tilde_fence(self):
|
|
225
|
+
"""Works with tilde fence markers."""
|
|
226
|
+
source_lines = ["~~~", "# [x] extract", "content", "~~~"]
|
|
227
|
+
detector = FenceDetector(source_lines)
|
|
228
|
+
|
|
229
|
+
result = detector.is_extract_fence(0)
|
|
230
|
+
|
|
231
|
+
assert result is True
|
|
232
|
+
|
|
233
|
+
def test_find_matching_fence_end_finds_closing_fence(self):
|
|
234
|
+
"""Finds the matching closing fence."""
|
|
235
|
+
source_lines = ["```", "# [x] extract", "content", "```"]
|
|
236
|
+
detector = FenceDetector(source_lines)
|
|
237
|
+
|
|
238
|
+
result = detector.find_matching_fence_end(0)
|
|
239
|
+
|
|
240
|
+
assert result == 3
|
|
241
|
+
|
|
242
|
+
def test_find_matching_fence_end_returns_minus_one_if_not_found(self):
|
|
243
|
+
"""Returns -1 if no matching fence found."""
|
|
244
|
+
source_lines = ["```", "# [x] extract", "content without closing"]
|
|
245
|
+
detector = FenceDetector(source_lines)
|
|
246
|
+
|
|
247
|
+
result = detector.find_matching_fence_end(0)
|
|
248
|
+
|
|
249
|
+
assert result == -1
|
|
250
|
+
|
|
251
|
+
def test_find_matching_fence_handles_nested_fences(self):
|
|
252
|
+
"""Handles nested fence blocks correctly."""
|
|
253
|
+
source_lines = ["````", "# [x] extract", "```python", "code = 1", "```", "````"]
|
|
254
|
+
detector = FenceDetector(source_lines)
|
|
255
|
+
|
|
256
|
+
result = detector.find_matching_fence_end(0)
|
|
257
|
+
|
|
258
|
+
assert result == 5
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# =============================================================================
|
|
262
|
+
# Tests for determine_should_create
|
|
263
|
+
# =============================================================================
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class TestDetermineShouldCreate:
|
|
267
|
+
"""Tests for determine_should_create function."""
|
|
268
|
+
|
|
269
|
+
def test_returns_true_when_skip_query_is_true(self):
|
|
270
|
+
"""Returns True when skip_query is True (force mode)."""
|
|
271
|
+
result = determine_should_create(skip_query=True)
|
|
272
|
+
assert result is True
|
|
273
|
+
|
|
274
|
+
@patch("ara_cli.prompt_extractor.prompt_user_decision", return_value="y")
|
|
275
|
+
def test_returns_true_when_user_confirms(self, mock_input):
|
|
276
|
+
"""Returns True when user confirms with 'y'."""
|
|
277
|
+
result = determine_should_create(skip_query=False)
|
|
278
|
+
assert result is True
|
|
279
|
+
|
|
280
|
+
@patch("ara_cli.prompt_extractor.prompt_user_decision", return_value="yes")
|
|
281
|
+
def test_returns_true_when_user_confirms_yes(self, mock_input):
|
|
282
|
+
"""Returns True when user confirms with 'yes'."""
|
|
283
|
+
result = determine_should_create(skip_query=False)
|
|
284
|
+
assert result is True
|
|
285
|
+
|
|
286
|
+
@patch("ara_cli.prompt_extractor.prompt_user_decision", return_value="n")
|
|
287
|
+
def test_returns_false_when_user_declines(self, mock_input):
|
|
288
|
+
"""Returns False when user declines."""
|
|
289
|
+
result = determine_should_create(skip_query=False)
|
|
290
|
+
assert result is False
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# =============================================================================
|
|
294
|
+
# Tests for create_file_if_not_exist
|
|
295
|
+
# =============================================================================
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class TestCreateFileIfNotExist:
|
|
299
|
+
"""Tests for create_file_if_not_exist function."""
|
|
300
|
+
|
|
301
|
+
def test_creates_file_when_not_exists_and_skip_query(self):
|
|
302
|
+
"""Creates file when it doesn't exist and skip_query=True."""
|
|
303
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
304
|
+
filepath = os.path.join(tmpdir, "new_file.txt")
|
|
305
|
+
content = "test content"
|
|
306
|
+
|
|
307
|
+
create_file_if_not_exist(filepath, content, skip_query=True)
|
|
308
|
+
|
|
309
|
+
assert os.path.exists(filepath)
|
|
310
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
311
|
+
assert f.read() == content
|
|
312
|
+
|
|
313
|
+
def test_creates_nested_directories(self):
|
|
314
|
+
"""Creates nested directories if they don't exist."""
|
|
315
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
316
|
+
filepath = os.path.join(tmpdir, "deep", "nested", "file.txt")
|
|
317
|
+
content = "test content"
|
|
318
|
+
|
|
319
|
+
create_file_if_not_exist(filepath, content, skip_query=True)
|
|
320
|
+
|
|
321
|
+
assert os.path.exists(filepath)
|
|
322
|
+
|
|
323
|
+
@patch("ara_cli.prompt_extractor.determine_should_create", return_value=False)
|
|
324
|
+
def test_does_not_create_when_user_declines(self, mock_determine):
|
|
325
|
+
"""Does not create file when user declines."""
|
|
326
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
327
|
+
filepath = os.path.join(tmpdir, "new_file.txt")
|
|
328
|
+
|
|
329
|
+
create_file_if_not_exist(filepath, "content", skip_query=False)
|
|
330
|
+
|
|
331
|
+
assert not os.path.exists(filepath)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# =============================================================================
|
|
335
|
+
# Tests for handle_existing_file
|
|
336
|
+
# =============================================================================
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class TestHandleExistingFile:
|
|
340
|
+
"""Tests for handle_existing_file function."""
|
|
341
|
+
|
|
342
|
+
def test_creates_new_file_when_not_exists(self):
|
|
343
|
+
"""Creates new file when it doesn't exist."""
|
|
344
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
345
|
+
filepath = os.path.join(tmpdir, "new_file.feature")
|
|
346
|
+
content = "@creator\nFeature: sample"
|
|
347
|
+
|
|
348
|
+
with patch(
|
|
349
|
+
"ara_cli.prompt_extractor.create_file_if_not_exist"
|
|
350
|
+
) as mock_create:
|
|
351
|
+
handle_existing_file(filepath, content, skip_query=True)
|
|
352
|
+
mock_create.assert_called_once_with(filepath, content, True)
|
|
353
|
+
|
|
354
|
+
def test_overwrites_file_with_write_flag(self):
|
|
355
|
+
"""Overwrites existing file when write flag is True."""
|
|
356
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
357
|
+
filepath = os.path.join(tmpdir, "existing.feature")
|
|
358
|
+
original_content = "original content"
|
|
359
|
+
new_content = "new content"
|
|
360
|
+
|
|
361
|
+
# Create existing file
|
|
362
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
363
|
+
f.write(original_content)
|
|
364
|
+
|
|
365
|
+
handle_existing_file(filepath, new_content, skip_query=False, write=True)
|
|
366
|
+
|
|
367
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
368
|
+
assert f.read() == new_content
|
|
369
|
+
|
|
370
|
+
@patch("ara_cli.prompt_extractor.send_prompt")
|
|
371
|
+
@patch("ara_cli.prompt_extractor.modify_and_save_file")
|
|
372
|
+
@patch("ara_cli.prompt_extractor.get_file_content", return_value="existing content")
|
|
373
|
+
def test_calls_llm_merge_when_file_exists_without_write_flag(
|
|
374
|
+
self, mock_get_content, mock_modify, mock_send
|
|
375
|
+
):
|
|
376
|
+
"""Calls LLM merge when file exists and write flag is False."""
|
|
377
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
378
|
+
filepath = os.path.join(tmpdir, "existing.feature")
|
|
379
|
+
|
|
380
|
+
# Create existing file
|
|
381
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
382
|
+
f.write("existing content")
|
|
383
|
+
|
|
384
|
+
# Mock the LLM response
|
|
385
|
+
mock_chunk = MagicMock()
|
|
386
|
+
mock_chunk.choices = [
|
|
387
|
+
MagicMock(
|
|
388
|
+
delta=MagicMock(content='{"filename": "test", "content": "merged"}')
|
|
389
|
+
)
|
|
390
|
+
]
|
|
391
|
+
mock_send.return_value = [mock_chunk]
|
|
392
|
+
|
|
393
|
+
handle_existing_file(filepath, "new content", skip_query=False, write=False)
|
|
394
|
+
|
|
395
|
+
mock_send.assert_called_once()
|
|
396
|
+
mock_modify.assert_called_once()
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# =============================================================================
|
|
400
|
+
# Tests for extract_responses
|
|
401
|
+
# =============================================================================
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
class TestExtractResponses:
|
|
405
|
+
"""Tests for extract_responses function."""
|
|
406
|
+
|
|
407
|
+
def test_marks_extracted_blocks_with_checkmark(self):
|
|
408
|
+
"""Changes [x] to [v] in extracted blocks."""
|
|
409
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
410
|
+
filepath = os.path.join(tmpdir, "chat.md")
|
|
411
|
+
content = """# ara prompt:
|
|
412
|
+
Some text
|
|
413
|
+
|
|
414
|
+
```
|
|
415
|
+
# [x] extract
|
|
416
|
+
@creator_unknown
|
|
417
|
+
Feature: sample feature
|
|
418
|
+
|
|
419
|
+
As a user
|
|
420
|
+
I want to test
|
|
421
|
+
So that I verify
|
|
422
|
+
|
|
423
|
+
Contributes to
|
|
424
|
+
|
|
425
|
+
Description:
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
more text
|
|
429
|
+
"""
|
|
430
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
431
|
+
f.write(content)
|
|
432
|
+
|
|
433
|
+
with patch("ara_cli.prompt_extractor._process_artefact_extraction"):
|
|
434
|
+
extract_responses(filepath, relative_to_ara_root=False, force=True)
|
|
435
|
+
|
|
436
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
437
|
+
result = f.read()
|
|
438
|
+
|
|
439
|
+
assert "# [v] extract" in result
|
|
440
|
+
assert "# [x] extract" not in result
|
|
441
|
+
|
|
442
|
+
def test_handles_file_not_found(self, capsys):
|
|
443
|
+
"""Handles file not found gracefully."""
|
|
444
|
+
extract_responses("/nonexistent/path/file.md")
|
|
445
|
+
|
|
446
|
+
captured = capsys.readouterr()
|
|
447
|
+
assert "Error: File not found" in captured.out
|
|
448
|
+
|
|
449
|
+
def test_processes_multiple_blocks(self):
|
|
450
|
+
"""Processes multiple extract blocks in a document."""
|
|
451
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
452
|
+
filepath = os.path.join(tmpdir, "chat.md")
|
|
453
|
+
content = """```
|
|
454
|
+
# [x] extract
|
|
455
|
+
@creator
|
|
456
|
+
Feature: first
|
|
457
|
+
|
|
458
|
+
As a user
|
|
459
|
+
I want one
|
|
460
|
+
So that one
|
|
461
|
+
|
|
462
|
+
Contributes to
|
|
463
|
+
|
|
464
|
+
Description:
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
```
|
|
468
|
+
# [x] extract
|
|
469
|
+
@creator
|
|
470
|
+
Task: second
|
|
471
|
+
|
|
472
|
+
Contributes to
|
|
473
|
+
|
|
474
|
+
Description:
|
|
475
|
+
```
|
|
476
|
+
"""
|
|
477
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
478
|
+
f.write(content)
|
|
479
|
+
|
|
480
|
+
with patch("ara_cli.prompt_extractor._process_artefact_extraction"):
|
|
481
|
+
extract_responses(filepath, relative_to_ara_root=False, force=True)
|
|
482
|
+
|
|
483
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
484
|
+
result = f.read()
|
|
485
|
+
|
|
486
|
+
assert result.count("# [v] extract") == 2
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# =============================================================================
|
|
490
|
+
# Tests for _process_document_blocks
|
|
491
|
+
# =============================================================================
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class TestProcessDocumentBlocks:
|
|
495
|
+
"""Tests for _process_document_blocks function."""
|
|
496
|
+
|
|
497
|
+
def test_returns_empty_list_for_no_blocks(self):
|
|
498
|
+
"""Returns empty list when no extract blocks found."""
|
|
499
|
+
source_lines = ["regular text", "no blocks here"]
|
|
500
|
+
|
|
501
|
+
result = _process_document_blocks(source_lines, force=True, write=False)
|
|
502
|
+
|
|
503
|
+
assert result == []
|
|
504
|
+
|
|
505
|
+
@patch("ara_cli.prompt_extractor._perform_extraction_for_block")
|
|
506
|
+
def test_processes_each_block(self, mock_perform):
|
|
507
|
+
"""Processes each extract block found."""
|
|
508
|
+
mock_perform.return_value = ("original", "modified")
|
|
509
|
+
source_lines = ["```", "# [x] extract", "@creator", "Feature: test", "```"]
|
|
510
|
+
|
|
511
|
+
result = _process_document_blocks(source_lines, force=True, write=False)
|
|
512
|
+
|
|
513
|
+
assert len(result) == 1
|
|
514
|
+
mock_perform.assert_called_once()
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
# =============================================================================
|
|
518
|
+
# Tests for _apply_replacements
|
|
519
|
+
# =============================================================================
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
class TestApplyReplacements:
|
|
523
|
+
"""Tests for _apply_replacements function."""
|
|
524
|
+
|
|
525
|
+
def test_applies_single_replacement(self):
|
|
526
|
+
"""Applies a single replacement correctly."""
|
|
527
|
+
content = "original text here"
|
|
528
|
+
replacements = [("original", "modified")]
|
|
529
|
+
|
|
530
|
+
result = _apply_replacements(content, replacements)
|
|
531
|
+
|
|
532
|
+
assert result == "modified text here"
|
|
533
|
+
|
|
534
|
+
def test_applies_multiple_replacements(self):
|
|
535
|
+
"""Applies multiple replacements correctly."""
|
|
536
|
+
content = "first second third"
|
|
537
|
+
replacements = [("first", "1st"), ("second", "2nd")]
|
|
538
|
+
|
|
539
|
+
result = _apply_replacements(content, replacements)
|
|
540
|
+
|
|
541
|
+
assert result == "1st 2nd third"
|
|
542
|
+
|
|
543
|
+
def test_returns_original_for_empty_replacements(self):
|
|
544
|
+
"""Returns original content when no replacements."""
|
|
545
|
+
content = "unchanged content"
|
|
546
|
+
|
|
547
|
+
result = _apply_replacements(content, [])
|
|
548
|
+
|
|
549
|
+
assert result == content
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# =============================================================================
|
|
553
|
+
# Tests for artefact type extraction (covers all artefact types from features)
|
|
554
|
+
# =============================================================================
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class TestArtefactTypeExtraction:
|
|
558
|
+
"""Tests that all artefact types can be extracted."""
|
|
559
|
+
|
|
560
|
+
@pytest.mark.parametrize(
|
|
561
|
+
"artefact_type,prefix,subdir",
|
|
562
|
+
[
|
|
563
|
+
("businessgoal", "Businessgoal:", "businessgoals"),
|
|
564
|
+
("capability", "Capability:", "capabilities"),
|
|
565
|
+
("epic", "Epic:", "epics"),
|
|
566
|
+
("example", "Example:", "examples"),
|
|
567
|
+
("feature", "Feature:", "features"),
|
|
568
|
+
("issue", "Issue:", "issues"),
|
|
569
|
+
("keyfeature", "Keyfeature:", "keyfeatures"),
|
|
570
|
+
("task", "Task:", "tasks"),
|
|
571
|
+
("vision", "Vision:", "vision"),
|
|
572
|
+
],
|
|
573
|
+
)
|
|
574
|
+
def test_artefact_class_found_for_type(self, artefact_type, prefix, subdir):
|
|
575
|
+
"""Artefact class is found for each supported type."""
|
|
576
|
+
content_lines = [f"@creator", f"{prefix} sample {artefact_type}"]
|
|
577
|
+
|
|
578
|
+
result = _find_artefact_class(content_lines)
|
|
579
|
+
|
|
580
|
+
assert result is not None
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# =============================================================================
|
|
584
|
+
# Tests for force flag behavior (from agile_artefact_extraction_force.feature)
|
|
585
|
+
# =============================================================================
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
class TestForceFlag:
|
|
589
|
+
"""Tests for force flag functionality."""
|
|
590
|
+
|
|
591
|
+
def test_force_flag_skips_user_confirmation(self):
|
|
592
|
+
"""Force flag skips user confirmation."""
|
|
593
|
+
result = determine_should_create(skip_query=True)
|
|
594
|
+
assert result is True
|
|
595
|
+
|
|
596
|
+
def test_force_flag_creates_file_immediately(self):
|
|
597
|
+
"""Force flag creates file without prompting."""
|
|
598
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
599
|
+
filepath = os.path.join(tmpdir, "forced.feature")
|
|
600
|
+
content = "test content"
|
|
601
|
+
|
|
602
|
+
# With force (skip_query=True), file should be created without prompt
|
|
603
|
+
create_file_if_not_exist(filepath, content, skip_query=True)
|
|
604
|
+
|
|
605
|
+
assert os.path.exists(filepath)
|
|
606
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
607
|
+
assert f.read() == content
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# =============================================================================
|
|
611
|
+
# Tests for write flag behavior (from agile_artefact_extraction_override.feature)
|
|
612
|
+
# =============================================================================
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class TestWriteFlag:
|
|
616
|
+
"""Tests for write flag functionality."""
|
|
617
|
+
|
|
618
|
+
def test_write_flag_overwrites_existing_file(self):
|
|
619
|
+
"""Write flag overwrites existing file without LLM merge."""
|
|
620
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
621
|
+
filepath = os.path.join(tmpdir, "existing.feature")
|
|
622
|
+
original = "original content"
|
|
623
|
+
new_content = "new content"
|
|
624
|
+
|
|
625
|
+
# Create existing file
|
|
626
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
627
|
+
f.write(original)
|
|
628
|
+
|
|
629
|
+
handle_existing_file(filepath, new_content, skip_query=False, write=True)
|
|
630
|
+
|
|
631
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
632
|
+
assert f.read() == new_content
|
|
633
|
+
|
|
634
|
+
def test_write_flag_prints_overwrite_message(self, capsys):
|
|
635
|
+
"""Write flag prints overwrite message."""
|
|
636
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
637
|
+
filepath = os.path.join(tmpdir, "existing.feature")
|
|
638
|
+
|
|
639
|
+
with open(filepath, "w", encoding="utf-8") as f:
|
|
640
|
+
f.write("original")
|
|
641
|
+
|
|
642
|
+
handle_existing_file(filepath, "new", skip_query=False, write=True)
|
|
643
|
+
|
|
644
|
+
captured = capsys.readouterr()
|
|
645
|
+
assert "Overwriting without LLM merge as requested" in captured.out
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
# =============================================================================
|
|
649
|
+
# Tests for modify_and_save_file
|
|
650
|
+
# =============================================================================
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
class TestModifyAndSaveFile:
|
|
654
|
+
"""Tests for modify_and_save_file function."""
|
|
655
|
+
|
|
656
|
+
def test_saves_content_from_json_response(self):
|
|
657
|
+
"""Saves content from valid JSON response."""
|
|
658
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
659
|
+
filepath = os.path.join(tmpdir, "test.feature")
|
|
660
|
+
response = '{"filename": "' + filepath + '", "content": "merged content"}'
|
|
661
|
+
|
|
662
|
+
modify_and_save_file(response, filepath)
|
|
663
|
+
|
|
664
|
+
with open(filepath, "r", encoding="utf-8") as f:
|
|
665
|
+
assert f.read() == "merged content"
|
|
666
|
+
|
|
667
|
+
@patch("ara_cli.prompt_extractor.prompt_user_decision", return_value="y")
|
|
668
|
+
def test_prompts_on_filename_mismatch(self, mock_input):
|
|
669
|
+
"""Prompts user when filename in response doesn't match."""
|
|
670
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
671
|
+
filepath = os.path.join(tmpdir, "test.feature")
|
|
672
|
+
response = '{"filename": "different.feature", "content": "content"}'
|
|
673
|
+
|
|
674
|
+
modify_and_save_file(response, filepath)
|
|
675
|
+
|
|
676
|
+
mock_input.assert_called_once()
|
|
677
|
+
|
|
678
|
+
def test_handles_invalid_json(self, capsys):
|
|
679
|
+
"""Handles invalid JSON that json_repair cannot fix properly."""
|
|
680
|
+
# json_repair may convert invalid JSON to a string, causing TypeError
|
|
681
|
+
# when trying to access dict keys - this is expected behavior
|
|
682
|
+
with pytest.raises(TypeError):
|
|
683
|
+
modify_and_save_file("not valid json", "test.feature")
|