julee 0.1.5__py3-none-any.whl → 0.1.6__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.
Files changed (105) hide show
  1. julee/docs/sphinx_hcd/__init__.py +146 -13
  2. julee/docs/sphinx_hcd/domain/__init__.py +5 -0
  3. julee/docs/sphinx_hcd/domain/models/__init__.py +32 -0
  4. julee/docs/sphinx_hcd/domain/models/accelerator.py +152 -0
  5. julee/docs/sphinx_hcd/domain/models/app.py +151 -0
  6. julee/docs/sphinx_hcd/domain/models/code_info.py +121 -0
  7. julee/docs/sphinx_hcd/domain/models/epic.py +79 -0
  8. julee/docs/sphinx_hcd/domain/models/integration.py +230 -0
  9. julee/docs/sphinx_hcd/domain/models/journey.py +222 -0
  10. julee/docs/sphinx_hcd/domain/models/persona.py +106 -0
  11. julee/docs/sphinx_hcd/domain/models/story.py +128 -0
  12. julee/docs/sphinx_hcd/domain/repositories/__init__.py +25 -0
  13. julee/docs/sphinx_hcd/domain/repositories/accelerator.py +98 -0
  14. julee/docs/sphinx_hcd/domain/repositories/app.py +57 -0
  15. julee/docs/sphinx_hcd/domain/repositories/base.py +89 -0
  16. julee/docs/sphinx_hcd/domain/repositories/code_info.py +69 -0
  17. julee/docs/sphinx_hcd/domain/repositories/epic.py +62 -0
  18. julee/docs/sphinx_hcd/domain/repositories/integration.py +79 -0
  19. julee/docs/sphinx_hcd/domain/repositories/journey.py +106 -0
  20. julee/docs/sphinx_hcd/domain/repositories/story.py +68 -0
  21. julee/docs/sphinx_hcd/domain/use_cases/__init__.py +64 -0
  22. julee/docs/sphinx_hcd/domain/use_cases/derive_personas.py +166 -0
  23. julee/docs/sphinx_hcd/domain/use_cases/resolve_accelerator_references.py +236 -0
  24. julee/docs/sphinx_hcd/domain/use_cases/resolve_app_references.py +144 -0
  25. julee/docs/sphinx_hcd/domain/use_cases/resolve_story_references.py +121 -0
  26. julee/docs/sphinx_hcd/parsers/__init__.py +48 -0
  27. julee/docs/sphinx_hcd/parsers/ast.py +150 -0
  28. julee/docs/sphinx_hcd/parsers/gherkin.py +155 -0
  29. julee/docs/sphinx_hcd/parsers/yaml.py +184 -0
  30. julee/docs/sphinx_hcd/repositories/__init__.py +4 -0
  31. julee/docs/sphinx_hcd/repositories/memory/__init__.py +25 -0
  32. julee/docs/sphinx_hcd/repositories/memory/accelerator.py +86 -0
  33. julee/docs/sphinx_hcd/repositories/memory/app.py +45 -0
  34. julee/docs/sphinx_hcd/repositories/memory/base.py +106 -0
  35. julee/docs/sphinx_hcd/repositories/memory/code_info.py +59 -0
  36. julee/docs/sphinx_hcd/repositories/memory/epic.py +54 -0
  37. julee/docs/sphinx_hcd/repositories/memory/integration.py +70 -0
  38. julee/docs/sphinx_hcd/repositories/memory/journey.py +96 -0
  39. julee/docs/sphinx_hcd/repositories/memory/story.py +63 -0
  40. julee/docs/sphinx_hcd/sphinx/__init__.py +28 -0
  41. julee/docs/sphinx_hcd/sphinx/adapters.py +116 -0
  42. julee/docs/sphinx_hcd/sphinx/context.py +163 -0
  43. julee/docs/sphinx_hcd/sphinx/directives/__init__.py +160 -0
  44. julee/docs/sphinx_hcd/sphinx/directives/accelerator.py +576 -0
  45. julee/docs/sphinx_hcd/sphinx/directives/app.py +349 -0
  46. julee/docs/sphinx_hcd/sphinx/directives/base.py +211 -0
  47. julee/docs/sphinx_hcd/sphinx/directives/epic.py +434 -0
  48. julee/docs/sphinx_hcd/sphinx/directives/integration.py +220 -0
  49. julee/docs/sphinx_hcd/sphinx/directives/journey.py +642 -0
  50. julee/docs/sphinx_hcd/sphinx/directives/persona.py +345 -0
  51. julee/docs/sphinx_hcd/sphinx/directives/story.py +575 -0
  52. julee/docs/sphinx_hcd/sphinx/event_handlers/__init__.py +16 -0
  53. julee/docs/sphinx_hcd/sphinx/event_handlers/builder_inited.py +31 -0
  54. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_read.py +27 -0
  55. julee/docs/sphinx_hcd/sphinx/event_handlers/doctree_resolved.py +43 -0
  56. julee/docs/sphinx_hcd/sphinx/event_handlers/env_purge_doc.py +42 -0
  57. julee/docs/sphinx_hcd/sphinx/initialization.py +139 -0
  58. julee/docs/sphinx_hcd/tests/__init__.py +9 -0
  59. julee/docs/sphinx_hcd/tests/conftest.py +6 -0
  60. julee/docs/sphinx_hcd/tests/domain/__init__.py +1 -0
  61. julee/docs/sphinx_hcd/tests/domain/models/__init__.py +1 -0
  62. julee/docs/sphinx_hcd/tests/domain/models/test_accelerator.py +266 -0
  63. julee/docs/sphinx_hcd/tests/domain/models/test_app.py +258 -0
  64. julee/docs/sphinx_hcd/tests/domain/models/test_code_info.py +231 -0
  65. julee/docs/sphinx_hcd/tests/domain/models/test_epic.py +163 -0
  66. julee/docs/sphinx_hcd/tests/domain/models/test_integration.py +327 -0
  67. julee/docs/sphinx_hcd/tests/domain/models/test_journey.py +249 -0
  68. julee/docs/sphinx_hcd/tests/domain/models/test_persona.py +172 -0
  69. julee/docs/sphinx_hcd/tests/domain/models/test_story.py +216 -0
  70. julee/docs/sphinx_hcd/tests/domain/use_cases/__init__.py +1 -0
  71. julee/docs/sphinx_hcd/tests/domain/use_cases/test_derive_personas.py +314 -0
  72. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_accelerator_references.py +476 -0
  73. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_app_references.py +265 -0
  74. julee/docs/sphinx_hcd/tests/domain/use_cases/test_resolve_story_references.py +229 -0
  75. julee/docs/sphinx_hcd/tests/integration/__init__.py +1 -0
  76. julee/docs/sphinx_hcd/tests/parsers/__init__.py +1 -0
  77. julee/docs/sphinx_hcd/tests/parsers/test_ast.py +298 -0
  78. julee/docs/sphinx_hcd/tests/parsers/test_gherkin.py +282 -0
  79. julee/docs/sphinx_hcd/tests/parsers/test_yaml.py +496 -0
  80. julee/docs/sphinx_hcd/tests/repositories/__init__.py +1 -0
  81. julee/docs/sphinx_hcd/tests/repositories/test_accelerator.py +298 -0
  82. julee/docs/sphinx_hcd/tests/repositories/test_app.py +218 -0
  83. julee/docs/sphinx_hcd/tests/repositories/test_base.py +151 -0
  84. julee/docs/sphinx_hcd/tests/repositories/test_code_info.py +253 -0
  85. julee/docs/sphinx_hcd/tests/repositories/test_epic.py +237 -0
  86. julee/docs/sphinx_hcd/tests/repositories/test_integration.py +268 -0
  87. julee/docs/sphinx_hcd/tests/repositories/test_journey.py +294 -0
  88. julee/docs/sphinx_hcd/tests/repositories/test_story.py +236 -0
  89. julee/docs/sphinx_hcd/tests/sphinx/__init__.py +1 -0
  90. julee/docs/sphinx_hcd/tests/sphinx/directives/__init__.py +1 -0
  91. julee/docs/sphinx_hcd/tests/sphinx/directives/test_base.py +160 -0
  92. julee/docs/sphinx_hcd/tests/sphinx/test_adapters.py +176 -0
  93. julee/docs/sphinx_hcd/tests/sphinx/test_context.py +257 -0
  94. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/METADATA +2 -1
  95. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/RECORD +98 -13
  96. julee/docs/sphinx_hcd/accelerators.py +0 -1175
  97. julee/docs/sphinx_hcd/apps.py +0 -518
  98. julee/docs/sphinx_hcd/epics.py +0 -453
  99. julee/docs/sphinx_hcd/integrations.py +0 -310
  100. julee/docs/sphinx_hcd/journeys.py +0 -797
  101. julee/docs/sphinx_hcd/personas.py +0 -457
  102. julee/docs/sphinx_hcd/stories.py +0 -960
  103. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/WHEEL +0 -0
  104. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/licenses/LICENSE +0 -0
  105. {julee-0.1.5.dist-info → julee-0.1.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,298 @@
1
+ """Tests for AST parser."""
2
+
3
+ from pathlib import Path
4
+
5
+ from julee.docs.sphinx_hcd.parsers.ast import (
6
+ parse_bounded_context,
7
+ parse_module_docstring,
8
+ parse_python_classes,
9
+ scan_bounded_contexts,
10
+ )
11
+
12
+
13
+ class TestParsePythonClasses:
14
+ """Test parse_python_classes function."""
15
+
16
+ def test_parse_single_class(self, tmp_path: Path) -> None:
17
+ """Test parsing a file with a single class."""
18
+ py_file = tmp_path / "document.py"
19
+ py_file.write_text(
20
+ '''
21
+ class Document:
22
+ """A document entity."""
23
+ pass
24
+ '''
25
+ )
26
+
27
+ classes = parse_python_classes(tmp_path)
28
+ assert len(classes) == 1
29
+ assert classes[0].name == "Document"
30
+ assert classes[0].docstring == "A document entity."
31
+ assert classes[0].file == "document.py"
32
+
33
+ def test_parse_multiple_classes(self, tmp_path: Path) -> None:
34
+ """Test parsing a file with multiple classes."""
35
+ py_file = tmp_path / "models.py"
36
+ py_file.write_text(
37
+ '''
38
+ class Document:
39
+ """A document entity."""
40
+ pass
41
+
42
+ class Term:
43
+ """A term in a vocabulary."""
44
+ pass
45
+ '''
46
+ )
47
+
48
+ classes = parse_python_classes(tmp_path)
49
+ assert len(classes) == 2
50
+ names = {c.name for c in classes}
51
+ assert names == {"Document", "Term"}
52
+
53
+ def test_parse_class_no_docstring(self, tmp_path: Path) -> None:
54
+ """Test parsing a class without a docstring."""
55
+ py_file = tmp_path / "simple.py"
56
+ py_file.write_text(
57
+ """
58
+ class SimpleClass:
59
+ pass
60
+ """
61
+ )
62
+
63
+ classes = parse_python_classes(tmp_path)
64
+ assert len(classes) == 1
65
+ assert classes[0].name == "SimpleClass"
66
+ assert classes[0].docstring == ""
67
+
68
+ def test_parse_multiline_docstring_extracts_first_line(
69
+ self, tmp_path: Path
70
+ ) -> None:
71
+ """Test that only the first line of docstring is extracted."""
72
+ py_file = tmp_path / "complex.py"
73
+ py_file.write_text(
74
+ '''
75
+ class ComplexClass:
76
+ """First line of docstring.
77
+
78
+ More detailed description here.
79
+ With multiple lines.
80
+ """
81
+ pass
82
+ '''
83
+ )
84
+
85
+ classes = parse_python_classes(tmp_path)
86
+ assert len(classes) == 1
87
+ assert classes[0].docstring == "First line of docstring."
88
+
89
+ def test_skip_private_files(self, tmp_path: Path) -> None:
90
+ """Test that files starting with underscore are skipped."""
91
+ (tmp_path / "_private.py").write_text("class Private: pass")
92
+ (tmp_path / "__init__.py").write_text("class Init: pass")
93
+ (tmp_path / "public.py").write_text("class Public: pass")
94
+
95
+ classes = parse_python_classes(tmp_path)
96
+ assert len(classes) == 1
97
+ assert classes[0].name == "Public"
98
+
99
+ def test_nonexistent_directory(self) -> None:
100
+ """Test parsing nonexistent directory returns empty list."""
101
+ classes = parse_python_classes(Path("/nonexistent/path"))
102
+ assert classes == []
103
+
104
+ def test_sorted_by_name(self, tmp_path: Path) -> None:
105
+ """Test classes are sorted by name."""
106
+ py_file = tmp_path / "classes.py"
107
+ py_file.write_text(
108
+ """
109
+ class Zebra: pass
110
+ class Apple: pass
111
+ class Mango: pass
112
+ """
113
+ )
114
+
115
+ classes = parse_python_classes(tmp_path)
116
+ names = [c.name for c in classes]
117
+ assert names == ["Apple", "Mango", "Zebra"]
118
+
119
+ def test_syntax_error_handled(self, tmp_path: Path) -> None:
120
+ """Test that syntax errors are handled gracefully."""
121
+ py_file = tmp_path / "broken.py"
122
+ py_file.write_text("class Broken def invalid") # Invalid syntax
123
+ (tmp_path / "valid.py").write_text("class Valid: pass")
124
+
125
+ classes = parse_python_classes(tmp_path)
126
+ assert len(classes) == 1
127
+ assert classes[0].name == "Valid"
128
+
129
+
130
+ class TestParseModuleDocstring:
131
+ """Test parse_module_docstring function."""
132
+
133
+ def test_parse_module_with_docstring(self, tmp_path: Path) -> None:
134
+ """Test parsing a module with a docstring."""
135
+ py_file = tmp_path / "module.py"
136
+ py_file.write_text(
137
+ '''"""Module docstring.
138
+
139
+ More details about the module.
140
+ """
141
+
142
+ class SomeClass:
143
+ pass
144
+ '''
145
+ )
146
+
147
+ first_line, full = parse_module_docstring(py_file)
148
+ assert first_line == "Module docstring."
149
+ assert "More details" in full
150
+
151
+ def test_parse_module_no_docstring(self, tmp_path: Path) -> None:
152
+ """Test parsing a module without a docstring."""
153
+ py_file = tmp_path / "no_doc.py"
154
+ py_file.write_text(
155
+ """
156
+ class SomeClass:
157
+ pass
158
+ """
159
+ )
160
+
161
+ first_line, full = parse_module_docstring(py_file)
162
+ assert first_line is None
163
+ assert full is None
164
+
165
+ def test_parse_nonexistent_file(self) -> None:
166
+ """Test parsing nonexistent file."""
167
+ first_line, full = parse_module_docstring(Path("/nonexistent/file.py"))
168
+ assert first_line is None
169
+ assert full is None
170
+
171
+
172
+ class TestParseBoundedContext:
173
+ """Test parse_bounded_context function."""
174
+
175
+ def test_parse_full_context(self, tmp_path: Path) -> None:
176
+ """Test parsing a complete bounded context structure."""
177
+ # Create ADR 001-compliant structure
178
+ context_dir = tmp_path / "vocabulary"
179
+ (context_dir / "domain" / "models").mkdir(parents=True)
180
+ (context_dir / "domain" / "repositories").mkdir(parents=True)
181
+ (context_dir / "domain" / "services").mkdir(parents=True)
182
+ (context_dir / "use_cases").mkdir(parents=True)
183
+ (context_dir / "infrastructure").mkdir(parents=True)
184
+
185
+ # Module docstring
186
+ (context_dir / "__init__.py").write_text('"""Vocabulary management."""')
187
+
188
+ # Entity
189
+ (context_dir / "domain" / "models" / "vocabulary.py").write_text(
190
+ '''
191
+ class Vocabulary:
192
+ """A vocabulary catalog."""
193
+ pass
194
+ '''
195
+ )
196
+
197
+ # Use case
198
+ (context_dir / "use_cases" / "create.py").write_text(
199
+ '''
200
+ class CreateVocabulary:
201
+ """Create a new vocabulary."""
202
+ pass
203
+ '''
204
+ )
205
+
206
+ # Repository protocol
207
+ (context_dir / "domain" / "repositories" / "vocabulary.py").write_text(
208
+ '''
209
+ class VocabularyRepository:
210
+ """Repository for vocabularies."""
211
+ pass
212
+ '''
213
+ )
214
+
215
+ info = parse_bounded_context(context_dir)
216
+ assert info is not None
217
+ assert info.slug == "vocabulary"
218
+ assert info.objective == "Vocabulary management."
219
+ assert len(info.entities) == 1
220
+ assert info.entities[0].name == "Vocabulary"
221
+ assert len(info.use_cases) == 1
222
+ assert info.use_cases[0].name == "CreateVocabulary"
223
+ assert len(info.repository_protocols) == 1
224
+ assert info.has_infrastructure is True
225
+ assert info.code_dir == "vocabulary"
226
+
227
+ def test_parse_minimal_context(self, tmp_path: Path) -> None:
228
+ """Test parsing a minimal bounded context."""
229
+ context_dir = tmp_path / "simple"
230
+ context_dir.mkdir()
231
+ (context_dir / "__init__.py").write_text("")
232
+
233
+ info = parse_bounded_context(context_dir)
234
+ assert info is not None
235
+ assert info.slug == "simple"
236
+ assert info.entities == []
237
+ assert info.use_cases == []
238
+ assert info.has_infrastructure is False
239
+
240
+ def test_parse_nonexistent_context(self) -> None:
241
+ """Test parsing nonexistent context returns None."""
242
+ info = parse_bounded_context(Path("/nonexistent/context"))
243
+ assert info is None
244
+
245
+
246
+ class TestScanBoundedContexts:
247
+ """Test scan_bounded_contexts function."""
248
+
249
+ def test_scan_multiple_contexts(self, tmp_path: Path) -> None:
250
+ """Test scanning a directory with multiple contexts."""
251
+ # Create two contexts
252
+ for name in ["vocabulary", "traceability"]:
253
+ context_dir = tmp_path / name
254
+ context_dir.mkdir()
255
+ (context_dir / "__init__.py").write_text(f'"""{name.title()} module."""')
256
+ (context_dir / "domain" / "models").mkdir(parents=True)
257
+ (context_dir / "domain" / "models" / "entity.py").write_text(
258
+ f"class {name.title()}Entity: pass"
259
+ )
260
+
261
+ contexts = scan_bounded_contexts(tmp_path)
262
+ assert len(contexts) == 2
263
+ slugs = {c.slug for c in contexts}
264
+ assert slugs == {"vocabulary", "traceability"}
265
+
266
+ def test_scan_skips_hidden_directories(self, tmp_path: Path) -> None:
267
+ """Test that hidden directories are skipped."""
268
+ (tmp_path / ".hidden").mkdir()
269
+ (tmp_path / "__pycache__").mkdir()
270
+ (tmp_path / "_private").mkdir()
271
+ visible = tmp_path / "visible"
272
+ visible.mkdir()
273
+ (visible / "__init__.py").write_text("")
274
+
275
+ contexts = scan_bounded_contexts(tmp_path)
276
+ assert len(contexts) == 1
277
+ assert contexts[0].slug == "visible"
278
+
279
+ def test_scan_skips_files(self, tmp_path: Path) -> None:
280
+ """Test that files (not directories) are skipped."""
281
+ (tmp_path / "file.py").write_text("x = 1")
282
+ context_dir = tmp_path / "context"
283
+ context_dir.mkdir()
284
+ (context_dir / "__init__.py").write_text("")
285
+
286
+ contexts = scan_bounded_contexts(tmp_path)
287
+ assert len(contexts) == 1
288
+ assert contexts[0].slug == "context"
289
+
290
+ def test_scan_nonexistent_directory(self) -> None:
291
+ """Test scanning nonexistent directory returns empty list."""
292
+ contexts = scan_bounded_contexts(Path("/nonexistent/src"))
293
+ assert contexts == []
294
+
295
+ def test_scan_empty_directory(self, tmp_path: Path) -> None:
296
+ """Test scanning empty directory returns empty list."""
297
+ contexts = scan_bounded_contexts(tmp_path)
298
+ assert contexts == []
@@ -0,0 +1,282 @@
1
+ """Tests for Gherkin feature file parser."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+
7
+ from julee.docs.sphinx_hcd.parsers.gherkin import (
8
+ parse_feature_content,
9
+ parse_feature_file,
10
+ scan_feature_directory,
11
+ )
12
+
13
+
14
+ class TestParseFeatureContent:
15
+ """Test parse_feature_content function."""
16
+
17
+ def test_parse_complete_feature(self) -> None:
18
+ """Test parsing a complete feature file."""
19
+ content = """Feature: Submit Order
20
+
21
+ As a Customer
22
+ I want to submit my order
23
+ So that I can purchase products
24
+
25
+ Scenario: Successful submission
26
+ Given I have items in my cart
27
+ When I submit my order
28
+ Then the order is confirmed
29
+ """
30
+ result = parse_feature_content(content)
31
+
32
+ assert result.feature_title == "Submit Order"
33
+ assert result.persona == "Customer"
34
+ assert result.i_want == "submit my order"
35
+ assert result.so_that == "I can purchase products"
36
+ assert "Feature: Submit Order" in result.gherkin_snippet
37
+ assert "As a Customer" in result.gherkin_snippet
38
+ # Scenario should not be in snippet
39
+ assert "Scenario" not in result.gherkin_snippet
40
+
41
+ def test_parse_feature_with_as_an(self) -> None:
42
+ """Test parsing 'As an' variant."""
43
+ content = """Feature: Admin Dashboard
44
+
45
+ As an Administrator
46
+ I want to view the dashboard
47
+ So that I can monitor the system
48
+ """
49
+ result = parse_feature_content(content)
50
+ assert result.persona == "Administrator"
51
+
52
+ def test_parse_feature_missing_persona(self) -> None:
53
+ """Test parsing feature without persona defaults to 'unknown'."""
54
+ content = """Feature: Some Feature
55
+
56
+ I want to do something
57
+ So that I achieve a goal
58
+ """
59
+ result = parse_feature_content(content)
60
+ assert result.persona == "unknown"
61
+
62
+ def test_parse_feature_missing_i_want(self) -> None:
63
+ """Test parsing feature without I want defaults."""
64
+ content = """Feature: Some Feature
65
+
66
+ As a User
67
+ So that I achieve a goal
68
+ """
69
+ result = parse_feature_content(content)
70
+ assert result.i_want == "do something"
71
+
72
+ def test_parse_feature_missing_so_that(self) -> None:
73
+ """Test parsing feature without So that defaults."""
74
+ content = """Feature: Some Feature
75
+
76
+ As a User
77
+ I want to do something
78
+ """
79
+ result = parse_feature_content(content)
80
+ assert result.so_that == "achieve a goal"
81
+
82
+ def test_parse_feature_missing_title(self) -> None:
83
+ """Test parsing content without Feature line."""
84
+ content = """
85
+ As a User
86
+ I want to do something
87
+ """
88
+ result = parse_feature_content(content)
89
+ assert result.feature_title == "Unknown"
90
+
91
+ def test_snippet_stops_at_background(self) -> None:
92
+ """Test that snippet extraction stops at Background."""
93
+ content = """Feature: Test
94
+
95
+ As a User
96
+ I want to test
97
+
98
+ Background:
99
+ Given some setup
100
+ """
101
+ result = parse_feature_content(content)
102
+ assert "Background" not in result.gherkin_snippet
103
+
104
+ def test_snippet_stops_at_tags(self) -> None:
105
+ """Test that snippet extraction stops at tags."""
106
+ content = """Feature: Test
107
+
108
+ As a User
109
+ I want to test
110
+
111
+ @slow @integration
112
+ Scenario: Tagged scenario
113
+ """
114
+ result = parse_feature_content(content)
115
+ assert "@slow" not in result.gherkin_snippet
116
+
117
+ def test_parse_indented_content(self) -> None:
118
+ """Test parsing with various indentation."""
119
+ content = """Feature: Upload Document
120
+
121
+ As a Staff Member
122
+ I want to upload a document
123
+ So that it can be analyzed
124
+ """
125
+ result = parse_feature_content(content)
126
+ assert result.persona == "Staff Member"
127
+ assert result.i_want == "upload a document"
128
+
129
+
130
+ class TestParseFeatureFile:
131
+ """Test parse_feature_file function."""
132
+
133
+ @pytest.fixture
134
+ def temp_project(self, tmp_path: Path) -> Path:
135
+ """Create a temporary project structure."""
136
+ # Create feature directory structure
137
+ feature_dir = tmp_path / "tests" / "e2e" / "my-app" / "features"
138
+ feature_dir.mkdir(parents=True)
139
+ return tmp_path
140
+
141
+ def test_parse_feature_file_success(self, temp_project: Path) -> None:
142
+ """Test parsing a feature file."""
143
+ feature_dir = temp_project / "tests" / "e2e" / "my-app" / "features"
144
+ feature_file = feature_dir / "submit.feature"
145
+ feature_file.write_text(
146
+ """Feature: Submit Form
147
+
148
+ As a User
149
+ I want to submit a form
150
+ So that my data is saved
151
+
152
+ Scenario: Valid submission
153
+ Given I fill the form
154
+ When I submit
155
+ Then it succeeds
156
+ """
157
+ )
158
+
159
+ story = parse_feature_file(feature_file, temp_project)
160
+
161
+ assert story is not None
162
+ assert story.feature_title == "Submit Form"
163
+ assert story.persona == "User"
164
+ assert story.app_slug == "my-app"
165
+ assert "tests/e2e/my-app/features/submit.feature" in story.file_path
166
+
167
+ def test_parse_feature_file_with_explicit_app(self, temp_project: Path) -> None:
168
+ """Test parsing with explicit app slug override."""
169
+ feature_dir = temp_project / "tests" / "e2e" / "my-app" / "features"
170
+ feature_file = feature_dir / "test.feature"
171
+ feature_file.write_text("Feature: Test\n\n As a User\n")
172
+
173
+ story = parse_feature_file(feature_file, temp_project, app_slug="override-app")
174
+
175
+ assert story is not None
176
+ assert story.app_slug == "override-app"
177
+
178
+ def test_parse_feature_file_nonexistent(self, temp_project: Path) -> None:
179
+ """Test parsing a nonexistent file returns None."""
180
+ nonexistent = temp_project / "nonexistent.feature"
181
+ story = parse_feature_file(nonexistent, temp_project)
182
+ assert story is None
183
+
184
+ def test_parse_feature_file_outside_project_root(self, tmp_path: Path) -> None:
185
+ """Test parsing a feature file outside the project root logs warning but works."""
186
+ # Create feature file in tmp_path
187
+ feature_file = tmp_path / "test.feature"
188
+ feature_file.write_text("Feature: Test\n\n As a User\n I want to test\n")
189
+
190
+ # Use a different directory as project root (feature is outside)
191
+ project_root = tmp_path / "project"
192
+ project_root.mkdir()
193
+
194
+ story = parse_feature_file(feature_file, project_root)
195
+
196
+ assert story is not None
197
+ assert story.feature_title == "Test"
198
+ # File path should be the full path when outside project root
199
+ assert str(feature_file) in story.file_path or "test.feature" in story.file_path
200
+
201
+ def test_parse_feature_file_unknown_app_slug_structure(
202
+ self, tmp_path: Path
203
+ ) -> None:
204
+ """Test parsing feature file with non-standard path defaults to 'unknown' app."""
205
+ # Create a feature file not in tests/e2e/{app}/features/ structure
206
+ feature_dir = tmp_path / "features"
207
+ feature_dir.mkdir()
208
+ feature_file = feature_dir / "test.feature"
209
+ feature_file.write_text("Feature: Test\n\n As a User\n I want to test\n")
210
+
211
+ story = parse_feature_file(feature_file, tmp_path)
212
+
213
+ assert story is not None
214
+ assert story.app_slug == "unknown"
215
+
216
+ def test_parse_feature_file_short_path_defaults_to_unknown(
217
+ self, tmp_path: Path
218
+ ) -> None:
219
+ """Test parsing feature with path too short for app extraction defaults to 'unknown'."""
220
+ # Create feature file directly in tmp_path (path has < 4 parts)
221
+ feature_file = tmp_path / "test.feature"
222
+ feature_file.write_text("Feature: Test\n\n As a User\n I want to test\n")
223
+
224
+ story = parse_feature_file(feature_file, tmp_path)
225
+
226
+ assert story is not None
227
+ assert story.app_slug == "unknown"
228
+
229
+
230
+ class TestScanFeatureDirectory:
231
+ """Test scan_feature_directory function."""
232
+
233
+ @pytest.fixture
234
+ def temp_project(self, tmp_path: Path) -> Path:
235
+ """Create a temporary project with multiple apps."""
236
+ # Create app1 features
237
+ app1_dir = tmp_path / "tests" / "e2e" / "app-one" / "features"
238
+ app1_dir.mkdir(parents=True)
239
+ (app1_dir / "feature1.feature").write_text(
240
+ "Feature: Feature One\n\n As a User\n I want to do one\n"
241
+ )
242
+ (app1_dir / "feature2.feature").write_text(
243
+ "Feature: Feature Two\n\n As an Admin\n I want to do two\n"
244
+ )
245
+
246
+ # Create app2 features
247
+ app2_dir = tmp_path / "tests" / "e2e" / "app-two" / "features"
248
+ app2_dir.mkdir(parents=True)
249
+ (app2_dir / "feature3.feature").write_text(
250
+ "Feature: Feature Three\n\n As a Customer\n I want to do three\n"
251
+ )
252
+
253
+ return tmp_path
254
+
255
+ def test_scan_finds_all_features(self, temp_project: Path) -> None:
256
+ """Test scanning finds all feature files."""
257
+ feature_dir = temp_project / "tests" / "e2e"
258
+ stories = scan_feature_directory(feature_dir, temp_project)
259
+
260
+ assert len(stories) == 3
261
+ titles = {s.feature_title for s in stories}
262
+ assert titles == {"Feature One", "Feature Two", "Feature Three"}
263
+
264
+ def test_scan_extracts_apps(self, temp_project: Path) -> None:
265
+ """Test scanning correctly extracts app slugs."""
266
+ feature_dir = temp_project / "tests" / "e2e"
267
+ stories = scan_feature_directory(feature_dir, temp_project)
268
+
269
+ apps = {s.app_slug for s in stories}
270
+ assert apps == {"app-one", "app-two"}
271
+
272
+ def test_scan_nonexistent_directory(self, tmp_path: Path) -> None:
273
+ """Test scanning nonexistent directory returns empty list."""
274
+ stories = scan_feature_directory(tmp_path / "nonexistent", tmp_path)
275
+ assert stories == []
276
+
277
+ def test_scan_empty_directory(self, tmp_path: Path) -> None:
278
+ """Test scanning empty directory returns empty list."""
279
+ empty_dir = tmp_path / "empty"
280
+ empty_dir.mkdir()
281
+ stories = scan_feature_directory(empty_dir, tmp_path)
282
+ assert stories == []