atdd 0.4.6__py3-none-any.whl → 0.4.7__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.
- atdd/coach/commands/inventory.py +91 -3
- atdd/coach/commands/registry.py +114 -5
- atdd/coach/utils/config.py +131 -0
- atdd/coach/utils/train_spec_phase.py +97 -0
- atdd/coach/validators/shared_fixtures.py +68 -1
- atdd/coach/validators/test_train_registry.py +189 -0
- atdd/coder/validators/test_train_infrastructure.py +236 -2
- atdd/planner/schemas/train.schema.json +125 -2
- atdd/planner/validators/test_train_validation.py +667 -2
- atdd/tester/validators/test_train_backend_e2e.py +371 -0
- atdd/tester/validators/test_train_frontend_e2e.py +292 -0
- atdd/tester/validators/test_train_frontend_python.py +282 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/METADATA +1 -1
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/RECORD +18 -12
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/WHEEL +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/entry_points.txt +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/licenses/LICENSE +0 -0
- {atdd-0.4.6.dist-info → atdd-0.4.7.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tester validators: Train backend E2E convention validation.
|
|
3
|
+
|
|
4
|
+
Train First-Class Spec v0.6 Section 6: Backend E2E Conventions
|
|
5
|
+
|
|
6
|
+
Validates:
|
|
7
|
+
- SPEC-TRAIN-VAL-0022: Backend E2E path convention
|
|
8
|
+
- SPEC-TRAIN-VAL-0023: Backend E2E pytest markers
|
|
9
|
+
- SPEC-TRAIN-VAL-0024: Backend E2E @see annotation
|
|
10
|
+
- SPEC-TRAIN-VAL-0025: Runner evidence detection
|
|
11
|
+
|
|
12
|
+
Backend E2E tests should follow:
|
|
13
|
+
- Path: e2e/<theme>/test_<train_id>[_<slug>].py
|
|
14
|
+
- Marker: @pytest.mark.train("<train_id>")
|
|
15
|
+
- Docstring: @see plan/_trains/<train_id>.yaml
|
|
16
|
+
- Runner: Use TrainRunner or train_runner fixture
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
import re
|
|
21
|
+
import ast
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Dict, List, Any, Tuple, Optional
|
|
24
|
+
|
|
25
|
+
import atdd
|
|
26
|
+
from atdd.coach.utils.repo import find_repo_root
|
|
27
|
+
from atdd.coach.utils.train_spec_phase import (
|
|
28
|
+
TrainSpecPhase,
|
|
29
|
+
should_enforce,
|
|
30
|
+
emit_phase_warning
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Path constants
|
|
35
|
+
REPO_ROOT = find_repo_root()
|
|
36
|
+
E2E_DIR = REPO_ROOT / "e2e"
|
|
37
|
+
TRAINS_DIR = REPO_ROOT / "plan" / "_trains"
|
|
38
|
+
|
|
39
|
+
# Package resources
|
|
40
|
+
ATDD_PKG_DIR = Path(atdd.__file__).resolve().parent
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_all_train_ids() -> Dict[str, str]:
|
|
44
|
+
"""
|
|
45
|
+
Get all train IDs mapped to their themes.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Dict mapping train_id -> theme
|
|
49
|
+
"""
|
|
50
|
+
import yaml
|
|
51
|
+
|
|
52
|
+
train_to_theme = {}
|
|
53
|
+
trains_registry_path = REPO_ROOT / "plan" / "_trains.yaml"
|
|
54
|
+
|
|
55
|
+
if trains_registry_path.exists():
|
|
56
|
+
with open(trains_registry_path) as f:
|
|
57
|
+
data = yaml.safe_load(f)
|
|
58
|
+
|
|
59
|
+
for theme_key, categories in data.get("trains", {}).items():
|
|
60
|
+
theme = theme_key.split("-", 1)[1] if "-" in theme_key else theme_key
|
|
61
|
+
if isinstance(categories, dict):
|
|
62
|
+
for category_key, trains_list in categories.items():
|
|
63
|
+
if isinstance(trains_list, list):
|
|
64
|
+
for train in trains_list:
|
|
65
|
+
train_id = train.get("train_id")
|
|
66
|
+
if train_id:
|
|
67
|
+
train_to_theme[train_id] = theme
|
|
68
|
+
|
|
69
|
+
return train_to_theme
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_backend_e2e_tests() -> List[Tuple[Path, str]]:
|
|
73
|
+
"""
|
|
74
|
+
Find all backend E2E test files.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of (path, train_id_from_filename) tuples
|
|
78
|
+
"""
|
|
79
|
+
tests = []
|
|
80
|
+
|
|
81
|
+
if not E2E_DIR.exists():
|
|
82
|
+
return tests
|
|
83
|
+
|
|
84
|
+
# Pattern: e2e/<theme>/test_<train_id>*.py
|
|
85
|
+
for test_file in E2E_DIR.rglob("test_*.py"):
|
|
86
|
+
# Extract train_id from filename
|
|
87
|
+
filename = test_file.stem # e.g., test_0001-auth-session
|
|
88
|
+
match = re.match(r"test_(\d{4}-[a-z0-9-]+)", filename)
|
|
89
|
+
if match:
|
|
90
|
+
train_id = match.group(1)
|
|
91
|
+
tests.append((test_file, train_id))
|
|
92
|
+
|
|
93
|
+
return tests
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _extract_pytest_markers(file_path: Path) -> List[str]:
|
|
97
|
+
"""
|
|
98
|
+
Extract pytest marker values from a test file.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
List of @pytest.mark.train("<value>") values
|
|
102
|
+
"""
|
|
103
|
+
markers = []
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
107
|
+
content = f.read()
|
|
108
|
+
|
|
109
|
+
# Pattern: @pytest.mark.train("...")
|
|
110
|
+
pattern = r'@pytest\.mark\.train\(["\']([^"\']+)["\']\)'
|
|
111
|
+
matches = re.findall(pattern, content)
|
|
112
|
+
markers.extend(matches)
|
|
113
|
+
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
return markers
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _extract_see_annotations(file_path: Path) -> List[str]:
|
|
121
|
+
"""
|
|
122
|
+
Extract @see annotations from docstrings.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
List of @see values
|
|
126
|
+
"""
|
|
127
|
+
see_annotations = []
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
131
|
+
content = f.read()
|
|
132
|
+
|
|
133
|
+
# Pattern: @see <path> in docstrings
|
|
134
|
+
pattern = r'@see\s+([^\s\n]+)'
|
|
135
|
+
matches = re.findall(pattern, content)
|
|
136
|
+
see_annotations.extend(matches)
|
|
137
|
+
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
return see_annotations
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _detect_runner_evidence(file_path: Path) -> Dict[str, bool]:
|
|
145
|
+
"""
|
|
146
|
+
Detect TrainRunner usage evidence in a test file.
|
|
147
|
+
|
|
148
|
+
Returns:
|
|
149
|
+
Dict with evidence flags: fixture, import, call
|
|
150
|
+
"""
|
|
151
|
+
evidence = {
|
|
152
|
+
"fixture": False,
|
|
153
|
+
"import": False,
|
|
154
|
+
"call": False
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
159
|
+
content = f.read()
|
|
160
|
+
|
|
161
|
+
# Check for fixture usage
|
|
162
|
+
if "train_runner" in content or "TrainRunner" in content:
|
|
163
|
+
# Fixture pattern: def test_xxx(train_runner):
|
|
164
|
+
if re.search(r'def\s+test_\w+\s*\([^)]*train_runner', content):
|
|
165
|
+
evidence["fixture"] = True
|
|
166
|
+
|
|
167
|
+
# Import pattern
|
|
168
|
+
if re.search(r'from\s+[\w.]+\s+import\s+[^;]*TrainRunner', content):
|
|
169
|
+
evidence["import"] = True
|
|
170
|
+
|
|
171
|
+
# Call pattern: runner.execute() or train_runner.execute()
|
|
172
|
+
if re.search(r'\w+\.execute\s*\(', content):
|
|
173
|
+
evidence["call"] = True
|
|
174
|
+
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
return evidence
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ============================================================================
|
|
182
|
+
# BACKEND E2E VALIDATORS
|
|
183
|
+
# ============================================================================
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.platform
|
|
187
|
+
def test_backend_e2e_path_convention():
|
|
188
|
+
"""
|
|
189
|
+
SPEC-TRAIN-VAL-0022: Backend E2E path follows convention
|
|
190
|
+
|
|
191
|
+
Given: Backend E2E test files
|
|
192
|
+
When: Checking file paths
|
|
193
|
+
Then: Path follows e2e/<theme>/test_<train_id>[_<slug>].py
|
|
194
|
+
|
|
195
|
+
Section 6: Backend E2E Path Convention
|
|
196
|
+
"""
|
|
197
|
+
train_to_theme = _get_all_train_ids()
|
|
198
|
+
|
|
199
|
+
if not train_to_theme:
|
|
200
|
+
pytest.skip("No trains found in registry")
|
|
201
|
+
|
|
202
|
+
violations = []
|
|
203
|
+
e2e_tests = _find_backend_e2e_tests()
|
|
204
|
+
|
|
205
|
+
for test_path, train_id in e2e_tests:
|
|
206
|
+
if train_id not in train_to_theme:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
expected_theme = train_to_theme[train_id]
|
|
210
|
+
rel_path = test_path.relative_to(REPO_ROOT)
|
|
211
|
+
|
|
212
|
+
# Check if theme is in path
|
|
213
|
+
path_parts = rel_path.parts
|
|
214
|
+
if len(path_parts) >= 2:
|
|
215
|
+
actual_theme = path_parts[1] # e2e/<theme>/test_xxx.py
|
|
216
|
+
if actual_theme != expected_theme:
|
|
217
|
+
violations.append(
|
|
218
|
+
f"{rel_path}: expected theme '{expected_theme}' but found '{actual_theme}'"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if violations:
|
|
222
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
223
|
+
pytest.fail(
|
|
224
|
+
f"Backend E2E path violations:\n " + "\n ".join(violations) +
|
|
225
|
+
"\n\nExpected: e2e/<theme>/test_<train_id>[_<slug>].py"
|
|
226
|
+
)
|
|
227
|
+
else:
|
|
228
|
+
for violation in violations:
|
|
229
|
+
emit_phase_warning(
|
|
230
|
+
"SPEC-TRAIN-VAL-0022",
|
|
231
|
+
violation,
|
|
232
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.platform
|
|
237
|
+
def test_backend_e2e_pytest_markers():
|
|
238
|
+
"""
|
|
239
|
+
SPEC-TRAIN-VAL-0023: Backend E2E tests have @pytest.mark.train marker
|
|
240
|
+
|
|
241
|
+
Given: Backend E2E test files
|
|
242
|
+
When: Checking pytest markers
|
|
243
|
+
Then: Tests have @pytest.mark.train("<train_id>") decorator
|
|
244
|
+
|
|
245
|
+
Section 6: Backend E2E Markers
|
|
246
|
+
"""
|
|
247
|
+
e2e_tests = _find_backend_e2e_tests()
|
|
248
|
+
|
|
249
|
+
if not e2e_tests:
|
|
250
|
+
pytest.skip("No backend E2E tests found")
|
|
251
|
+
|
|
252
|
+
missing_markers = []
|
|
253
|
+
mismatched_markers = []
|
|
254
|
+
|
|
255
|
+
for test_path, train_id in e2e_tests:
|
|
256
|
+
markers = _extract_pytest_markers(test_path)
|
|
257
|
+
|
|
258
|
+
if not markers:
|
|
259
|
+
missing_markers.append(f"{test_path.name}: no @pytest.mark.train marker")
|
|
260
|
+
elif train_id not in markers:
|
|
261
|
+
mismatched_markers.append(
|
|
262
|
+
f"{test_path.name}: expected train_id '{train_id}' but found {markers}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
violations = missing_markers + mismatched_markers
|
|
266
|
+
|
|
267
|
+
if violations:
|
|
268
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
269
|
+
pytest.fail(
|
|
270
|
+
f"Backend E2E marker issues:\n " + "\n ".join(violations) +
|
|
271
|
+
"\n\nExpected: @pytest.mark.train(\"<train_id>\")"
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
for violation in violations:
|
|
275
|
+
emit_phase_warning(
|
|
276
|
+
"SPEC-TRAIN-VAL-0023",
|
|
277
|
+
violation,
|
|
278
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@pytest.mark.platform
|
|
283
|
+
def test_backend_e2e_see_annotation():
|
|
284
|
+
"""
|
|
285
|
+
SPEC-TRAIN-VAL-0024: Backend E2E tests have @see annotation in docstring
|
|
286
|
+
|
|
287
|
+
Given: Backend E2E test files
|
|
288
|
+
When: Checking docstrings
|
|
289
|
+
Then: Tests have @see plan/_trains/<train_id>.yaml annotation
|
|
290
|
+
|
|
291
|
+
Section 6: Backend E2E @see Annotation
|
|
292
|
+
"""
|
|
293
|
+
e2e_tests = _find_backend_e2e_tests()
|
|
294
|
+
|
|
295
|
+
if not e2e_tests:
|
|
296
|
+
pytest.skip("No backend E2E tests found")
|
|
297
|
+
|
|
298
|
+
missing_see = []
|
|
299
|
+
invalid_see = []
|
|
300
|
+
|
|
301
|
+
for test_path, train_id in e2e_tests:
|
|
302
|
+
see_annotations = _extract_see_annotations(test_path)
|
|
303
|
+
|
|
304
|
+
if not see_annotations:
|
|
305
|
+
missing_see.append(f"{test_path.name}: no @see annotation")
|
|
306
|
+
else:
|
|
307
|
+
# Check for valid train reference
|
|
308
|
+
expected_ref = f"plan/_trains/{train_id}.yaml"
|
|
309
|
+
has_valid_ref = any(expected_ref in see for see in see_annotations)
|
|
310
|
+
if not has_valid_ref:
|
|
311
|
+
invalid_see.append(
|
|
312
|
+
f"{test_path.name}: expected @see {expected_ref}"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
violations = missing_see + invalid_see
|
|
316
|
+
|
|
317
|
+
if violations:
|
|
318
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
319
|
+
pytest.fail(
|
|
320
|
+
f"Backend E2E @see annotation issues:\n " + "\n ".join(violations) +
|
|
321
|
+
"\n\nExpected: @see plan/_trains/<train_id>.yaml"
|
|
322
|
+
)
|
|
323
|
+
else:
|
|
324
|
+
for violation in violations:
|
|
325
|
+
emit_phase_warning(
|
|
326
|
+
"SPEC-TRAIN-VAL-0024",
|
|
327
|
+
violation,
|
|
328
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@pytest.mark.platform
|
|
333
|
+
def test_backend_e2e_runner_evidence():
|
|
334
|
+
"""
|
|
335
|
+
SPEC-TRAIN-VAL-0025: Backend E2E tests have runner evidence
|
|
336
|
+
|
|
337
|
+
Given: Backend E2E test files
|
|
338
|
+
When: Checking for TrainRunner usage
|
|
339
|
+
Then: Tests have fixture, import, or call evidence of runner usage
|
|
340
|
+
|
|
341
|
+
Section 6: Runner Evidence Detection
|
|
342
|
+
"""
|
|
343
|
+
e2e_tests = _find_backend_e2e_tests()
|
|
344
|
+
|
|
345
|
+
if not e2e_tests:
|
|
346
|
+
pytest.skip("No backend E2E tests found")
|
|
347
|
+
|
|
348
|
+
no_evidence = []
|
|
349
|
+
|
|
350
|
+
for test_path, train_id in e2e_tests:
|
|
351
|
+
evidence = _detect_runner_evidence(test_path)
|
|
352
|
+
|
|
353
|
+
has_any_evidence = evidence["fixture"] or evidence["import"] or evidence["call"]
|
|
354
|
+
if not has_any_evidence:
|
|
355
|
+
no_evidence.append(
|
|
356
|
+
f"{test_path.name}: no TrainRunner evidence found"
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if no_evidence:
|
|
360
|
+
if should_enforce(TrainSpecPhase.BACKEND_ENFORCEMENT):
|
|
361
|
+
pytest.fail(
|
|
362
|
+
f"Backend E2E tests missing runner evidence:\n " + "\n ".join(no_evidence) +
|
|
363
|
+
"\n\nExpected: train_runner fixture, TrainRunner import, or .execute() call"
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
for missing in no_evidence:
|
|
367
|
+
emit_phase_warning(
|
|
368
|
+
"SPEC-TRAIN-VAL-0025",
|
|
369
|
+
missing,
|
|
370
|
+
TrainSpecPhase.BACKEND_ENFORCEMENT
|
|
371
|
+
)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tester validators: Train frontend E2E convention validation.
|
|
3
|
+
|
|
4
|
+
Train First-Class Spec v0.6 Section 7: Frontend E2E Conventions
|
|
5
|
+
|
|
6
|
+
Validates:
|
|
7
|
+
- SPEC-TRAIN-VAL-0026: Frontend E2E path convention
|
|
8
|
+
- SPEC-TRAIN-VAL-0027: Frontend E2E @train annotation
|
|
9
|
+
- SPEC-TRAIN-VAL-0028: Frontend E2E @see annotation
|
|
10
|
+
|
|
11
|
+
Frontend (web) E2E tests should follow:
|
|
12
|
+
- Path: web/e2e/<train_id>/*.spec.ts
|
|
13
|
+
- Annotation: @train <train_id>
|
|
14
|
+
- Docstring: @see plan/_trains/<train_id>.yaml
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Tuple
|
|
21
|
+
|
|
22
|
+
import atdd
|
|
23
|
+
from atdd.coach.utils.repo import find_repo_root
|
|
24
|
+
from atdd.coach.utils.train_spec_phase import (
|
|
25
|
+
TrainSpecPhase,
|
|
26
|
+
should_enforce,
|
|
27
|
+
emit_phase_warning
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Path constants
|
|
32
|
+
REPO_ROOT = find_repo_root()
|
|
33
|
+
WEB_E2E_DIR = REPO_ROOT / "web" / "e2e"
|
|
34
|
+
TRAINS_DIR = REPO_ROOT / "plan" / "_trains"
|
|
35
|
+
|
|
36
|
+
# Package resources
|
|
37
|
+
ATDD_PKG_DIR = Path(atdd.__file__).resolve().parent
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _get_all_train_ids() -> List[str]:
|
|
41
|
+
"""
|
|
42
|
+
Get all train IDs from the registry.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of train_id strings
|
|
46
|
+
"""
|
|
47
|
+
import yaml
|
|
48
|
+
|
|
49
|
+
train_ids = []
|
|
50
|
+
trains_registry_path = REPO_ROOT / "plan" / "_trains.yaml"
|
|
51
|
+
|
|
52
|
+
if trains_registry_path.exists():
|
|
53
|
+
with open(trains_registry_path) as f:
|
|
54
|
+
data = yaml.safe_load(f)
|
|
55
|
+
|
|
56
|
+
for theme_key, categories in data.get("trains", {}).items():
|
|
57
|
+
if isinstance(categories, dict):
|
|
58
|
+
for category_key, trains_list in categories.items():
|
|
59
|
+
if isinstance(trains_list, list):
|
|
60
|
+
for train in trains_list:
|
|
61
|
+
train_id = train.get("train_id")
|
|
62
|
+
if train_id:
|
|
63
|
+
train_ids.append(train_id)
|
|
64
|
+
|
|
65
|
+
return train_ids
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _find_frontend_e2e_tests() -> List[Tuple[Path, str]]:
|
|
69
|
+
"""
|
|
70
|
+
Find all frontend E2E test files.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of (path, train_id_from_dirname) tuples
|
|
74
|
+
"""
|
|
75
|
+
tests = []
|
|
76
|
+
|
|
77
|
+
if not WEB_E2E_DIR.exists():
|
|
78
|
+
return tests
|
|
79
|
+
|
|
80
|
+
# Pattern: web/e2e/<train_id>/*.spec.ts
|
|
81
|
+
for spec_file in WEB_E2E_DIR.rglob("*.spec.ts"):
|
|
82
|
+
# Get train_id from parent directory
|
|
83
|
+
parent_dir = spec_file.parent
|
|
84
|
+
if parent_dir != WEB_E2E_DIR:
|
|
85
|
+
train_id = parent_dir.name
|
|
86
|
+
# Validate train_id pattern
|
|
87
|
+
if re.match(r"^\d{4}-[a-z0-9-]+$", train_id):
|
|
88
|
+
tests.append((spec_file, train_id))
|
|
89
|
+
|
|
90
|
+
return tests
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_train_annotations(file_path: Path) -> List[str]:
|
|
94
|
+
"""
|
|
95
|
+
Extract @train annotations from a TypeScript test file.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
List of @train values
|
|
99
|
+
"""
|
|
100
|
+
annotations = []
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
104
|
+
content = f.read()
|
|
105
|
+
|
|
106
|
+
# Pattern: @train <train_id> in comments
|
|
107
|
+
pattern = r'@train\s+([^\s\n\*]+)'
|
|
108
|
+
matches = re.findall(pattern, content)
|
|
109
|
+
annotations.extend(matches)
|
|
110
|
+
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
return annotations
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _extract_see_annotations(file_path: Path) -> List[str]:
|
|
118
|
+
"""
|
|
119
|
+
Extract @see annotations from a TypeScript test file.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
List of @see values
|
|
123
|
+
"""
|
|
124
|
+
see_annotations = []
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
128
|
+
content = f.read()
|
|
129
|
+
|
|
130
|
+
# Pattern: @see <path> in comments
|
|
131
|
+
pattern = r'@see\s+([^\s\n\*]+)'
|
|
132
|
+
matches = re.findall(pattern, content)
|
|
133
|
+
see_annotations.extend(matches)
|
|
134
|
+
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
return see_annotations
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ============================================================================
|
|
142
|
+
# FRONTEND E2E VALIDATORS
|
|
143
|
+
# ============================================================================
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.platform
|
|
147
|
+
def test_frontend_e2e_path_convention():
|
|
148
|
+
"""
|
|
149
|
+
SPEC-TRAIN-VAL-0026: Frontend E2E path follows convention
|
|
150
|
+
|
|
151
|
+
Given: Frontend E2E test files
|
|
152
|
+
When: Checking file paths
|
|
153
|
+
Then: Path follows web/e2e/<train_id>/*.spec.ts
|
|
154
|
+
|
|
155
|
+
Section 7: Frontend E2E Path Convention
|
|
156
|
+
"""
|
|
157
|
+
all_train_ids = set(_get_all_train_ids())
|
|
158
|
+
|
|
159
|
+
if not all_train_ids:
|
|
160
|
+
pytest.skip("No trains found in registry")
|
|
161
|
+
|
|
162
|
+
if not WEB_E2E_DIR.exists():
|
|
163
|
+
pytest.skip("No web/e2e/ directory found")
|
|
164
|
+
|
|
165
|
+
violations = []
|
|
166
|
+
e2e_tests = _find_frontend_e2e_tests()
|
|
167
|
+
|
|
168
|
+
for test_path, train_id in e2e_tests:
|
|
169
|
+
if train_id not in all_train_ids:
|
|
170
|
+
rel_path = test_path.relative_to(REPO_ROOT)
|
|
171
|
+
violations.append(
|
|
172
|
+
f"{rel_path}: directory '{train_id}' is not a registered train_id"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Also check for spec files directly in web/e2e/ (no train_id directory)
|
|
176
|
+
for spec_file in WEB_E2E_DIR.glob("*.spec.ts"):
|
|
177
|
+
rel_path = spec_file.relative_to(REPO_ROOT)
|
|
178
|
+
violations.append(
|
|
179
|
+
f"{rel_path}: spec file should be in web/e2e/<train_id>/ subdirectory"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if violations:
|
|
183
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
184
|
+
pytest.fail(
|
|
185
|
+
f"Frontend E2E path violations:\n " + "\n ".join(violations) +
|
|
186
|
+
"\n\nExpected: web/e2e/<train_id>/*.spec.ts"
|
|
187
|
+
)
|
|
188
|
+
else:
|
|
189
|
+
for violation in violations:
|
|
190
|
+
emit_phase_warning(
|
|
191
|
+
"SPEC-TRAIN-VAL-0026",
|
|
192
|
+
violation,
|
|
193
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@pytest.mark.platform
|
|
198
|
+
def test_frontend_e2e_train_annotation():
|
|
199
|
+
"""
|
|
200
|
+
SPEC-TRAIN-VAL-0027: Frontend E2E tests have @train annotation
|
|
201
|
+
|
|
202
|
+
Given: Frontend E2E test files
|
|
203
|
+
When: Checking for @train annotation
|
|
204
|
+
Then: Tests have @train <train_id> in JSDoc comment
|
|
205
|
+
|
|
206
|
+
Section 7: Frontend E2E @train Annotation
|
|
207
|
+
"""
|
|
208
|
+
e2e_tests = _find_frontend_e2e_tests()
|
|
209
|
+
|
|
210
|
+
if not e2e_tests:
|
|
211
|
+
pytest.skip("No frontend E2E tests found")
|
|
212
|
+
|
|
213
|
+
missing_annotation = []
|
|
214
|
+
mismatched_annotation = []
|
|
215
|
+
|
|
216
|
+
for test_path, train_id in e2e_tests:
|
|
217
|
+
annotations = _extract_train_annotations(test_path)
|
|
218
|
+
|
|
219
|
+
if not annotations:
|
|
220
|
+
missing_annotation.append(
|
|
221
|
+
f"{test_path.name}: no @train annotation"
|
|
222
|
+
)
|
|
223
|
+
elif train_id not in annotations:
|
|
224
|
+
mismatched_annotation.append(
|
|
225
|
+
f"{test_path.name}: expected @train {train_id} but found {annotations}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
violations = missing_annotation + mismatched_annotation
|
|
229
|
+
|
|
230
|
+
if violations:
|
|
231
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
232
|
+
pytest.fail(
|
|
233
|
+
f"Frontend E2E @train annotation issues:\n " + "\n ".join(violations) +
|
|
234
|
+
"\n\nExpected: @train <train_id> in JSDoc comment"
|
|
235
|
+
)
|
|
236
|
+
else:
|
|
237
|
+
for violation in violations:
|
|
238
|
+
emit_phase_warning(
|
|
239
|
+
"SPEC-TRAIN-VAL-0027",
|
|
240
|
+
violation,
|
|
241
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@pytest.mark.platform
|
|
246
|
+
def test_frontend_e2e_see_annotation():
|
|
247
|
+
"""
|
|
248
|
+
SPEC-TRAIN-VAL-0028: Frontend E2E tests have @see annotation
|
|
249
|
+
|
|
250
|
+
Given: Frontend E2E test files
|
|
251
|
+
When: Checking for @see annotation
|
|
252
|
+
Then: Tests have @see plan/_trains/<train_id>.yaml annotation
|
|
253
|
+
|
|
254
|
+
Section 7: Frontend E2E @see Annotation
|
|
255
|
+
"""
|
|
256
|
+
e2e_tests = _find_frontend_e2e_tests()
|
|
257
|
+
|
|
258
|
+
if not e2e_tests:
|
|
259
|
+
pytest.skip("No frontend E2E tests found")
|
|
260
|
+
|
|
261
|
+
missing_see = []
|
|
262
|
+
invalid_see = []
|
|
263
|
+
|
|
264
|
+
for test_path, train_id in e2e_tests:
|
|
265
|
+
see_annotations = _extract_see_annotations(test_path)
|
|
266
|
+
|
|
267
|
+
if not see_annotations:
|
|
268
|
+
missing_see.append(f"{test_path.name}: no @see annotation")
|
|
269
|
+
else:
|
|
270
|
+
# Check for valid train reference
|
|
271
|
+
expected_ref = f"plan/_trains/{train_id}.yaml"
|
|
272
|
+
has_valid_ref = any(expected_ref in see for see in see_annotations)
|
|
273
|
+
if not has_valid_ref:
|
|
274
|
+
invalid_see.append(
|
|
275
|
+
f"{test_path.name}: expected @see {expected_ref}"
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
violations = missing_see + invalid_see
|
|
279
|
+
|
|
280
|
+
if violations:
|
|
281
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
282
|
+
pytest.fail(
|
|
283
|
+
f"Frontend E2E @see annotation issues:\n " + "\n ".join(violations) +
|
|
284
|
+
"\n\nExpected: @see plan/_trains/<train_id>.yaml"
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
for violation in violations:
|
|
288
|
+
emit_phase_warning(
|
|
289
|
+
"SPEC-TRAIN-VAL-0028",
|
|
290
|
+
violation,
|
|
291
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
292
|
+
)
|