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.
@@ -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
+ )