atdd 0.4.7__py3-none-any.whl → 0.6.0__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,604 @@
1
+ """
2
+ Tester hierarchy coverage validation.
3
+
4
+ ATDD Hierarchy Coverage Spec v0.1 - Section 3: Tester Coverage Rules
5
+
6
+ Validates:
7
+ - Acceptance <-> Tests (COVERAGE-TEST-3.1)
8
+ - Contract <-> Wagon (COVERAGE-TEST-3.2)
9
+ - Telemetry <-> Wagon (COVERAGE-TEST-3.3)
10
+ - Telemetry tracking manifest (COVERAGE-TEST-3.4)
11
+
12
+ Architecture:
13
+ - Uses shared fixtures from atdd.coach.validators.shared_fixtures
14
+ - Phased rollout via atdd.coach.utils.coverage_phase
15
+ - Exception handling via .atdd/config.yaml coverage.exceptions
16
+ """
17
+
18
+ import pytest
19
+ import yaml
20
+ import json
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Dict, List, Set, Tuple, Any, Optional
24
+
25
+ from atdd.coach.utils.repo import find_repo_root
26
+ from atdd.coach.utils.coverage_phase import (
27
+ CoveragePhase,
28
+ should_enforce,
29
+ emit_coverage_warning
30
+ )
31
+
32
+
33
+ # Path constants
34
+ REPO_ROOT = find_repo_root()
35
+ PLAN_DIR = REPO_ROOT / "plan"
36
+ CONTRACTS_DIR = REPO_ROOT / "contracts"
37
+ TELEMETRY_DIR = REPO_ROOT / "telemetry"
38
+ PYTHON_DIR = REPO_ROOT / "python"
39
+ SUPABASE_DIR = REPO_ROOT / "supabase"
40
+ TEST_DIR = REPO_ROOT / "test"
41
+ E2E_DIR = REPO_ROOT / "e2e"
42
+
43
+
44
+ # ============================================================================
45
+ # HELPER FUNCTIONS
46
+ # ============================================================================
47
+
48
+
49
+ def find_acceptance_references_in_tests() -> Set[str]:
50
+ """
51
+ Scan test files for acceptance URN references.
52
+
53
+ Returns:
54
+ Set of acceptance URNs found in test files
55
+ """
56
+ urn_pattern = re.compile(
57
+ r'acc:[a-z][a-z0-9\-]*:[A-Z0-9]+-[A-Z0-9]+-\d{3}(?:-[a-z0-9-]+)?',
58
+ re.IGNORECASE
59
+ )
60
+
61
+ found_urns: Set[str] = set()
62
+
63
+ # Scan Python tests
64
+ if PYTHON_DIR.exists():
65
+ for test_file in PYTHON_DIR.rglob("test_*.py"):
66
+ try:
67
+ content = test_file.read_text(encoding="utf-8")
68
+ matches = urn_pattern.findall(content)
69
+ found_urns.update(matches)
70
+ except Exception:
71
+ pass
72
+
73
+ # Scan TypeScript tests
74
+ if SUPABASE_DIR.exists():
75
+ for test_file in SUPABASE_DIR.rglob("*.test.ts"):
76
+ try:
77
+ content = test_file.read_text(encoding="utf-8")
78
+ matches = urn_pattern.findall(content)
79
+ found_urns.update(matches)
80
+ except Exception:
81
+ pass
82
+
83
+ # Scan E2E tests
84
+ if E2E_DIR.exists():
85
+ for test_file in E2E_DIR.rglob("*.test.ts"):
86
+ try:
87
+ content = test_file.read_text(encoding="utf-8")
88
+ matches = urn_pattern.findall(content)
89
+ found_urns.update(matches)
90
+ except Exception:
91
+ pass
92
+
93
+ # Scan Dart tests
94
+ if TEST_DIR.exists():
95
+ for test_file in TEST_DIR.rglob("*_test.dart"):
96
+ try:
97
+ content = test_file.read_text(encoding="utf-8")
98
+ matches = urn_pattern.findall(content)
99
+ found_urns.update(matches)
100
+ except Exception:
101
+ pass
102
+
103
+ return found_urns
104
+
105
+
106
+ def get_contract_status(contract_path: Path) -> Optional[str]:
107
+ """
108
+ Extract status from contract schema x-artifact-metadata.
109
+
110
+ Returns:
111
+ Status string or None if not found
112
+ """
113
+ try:
114
+ with open(contract_path) as f:
115
+ data = json.load(f)
116
+ metadata = data.get("x-artifact-metadata", {})
117
+ return metadata.get("status")
118
+ except Exception:
119
+ return None
120
+
121
+
122
+ def get_telemetry_signal_status(signal_path: Path) -> Optional[str]:
123
+ """
124
+ Extract status from telemetry signal file.
125
+
126
+ Returns:
127
+ Status string or None if not found
128
+ """
129
+ try:
130
+ with open(signal_path) as f:
131
+ data = json.load(f)
132
+ return data.get("status")
133
+ except Exception:
134
+ return None
135
+
136
+
137
+ # ============================================================================
138
+ # COVERAGE-TEST-3.1: Acceptance <-> Tests Coverage
139
+ # ============================================================================
140
+
141
+
142
+ @pytest.mark.tester
143
+ def test_all_acceptances_have_tests(all_acceptance_urns, coverage_exceptions):
144
+ """
145
+ COVERAGE-TEST-3.1: Every acceptance has at least one test.
146
+
147
+ Given: All acceptance URNs from WMBT files
148
+ When: Scanning test files for URN references
149
+ Then: Every acceptance URN is referenced by at least one test
150
+ """
151
+ if not all_acceptance_urns:
152
+ pytest.skip("No acceptance URNs found in plan/")
153
+
154
+ allowed_acceptances = set(coverage_exceptions.get("acceptance_without_tests", []))
155
+
156
+ # Find all acceptance references in tests
157
+ test_references = find_acceptance_references_in_tests()
158
+
159
+ # Normalize URNs for comparison (case-insensitive)
160
+ test_references_lower = {urn.lower() for urn in test_references}
161
+
162
+ violations = []
163
+
164
+ for acceptance_urn in all_acceptance_urns:
165
+ # Skip allowed exceptions
166
+ if acceptance_urn in allowed_acceptances:
167
+ continue
168
+
169
+ # Check if acceptance is referenced (case-insensitive)
170
+ if acceptance_urn.lower() not in test_references_lower:
171
+ violations.append(acceptance_urn)
172
+
173
+ if violations:
174
+ if should_enforce(CoveragePhase.PLANNER_TESTER_ENFORCEMENT):
175
+ pytest.fail(
176
+ f"COVERAGE-TEST-3.1: Acceptances without tests ({len(violations)}):\n " +
177
+ "\n ".join(violations[:20]) +
178
+ (f"\n ... and {len(violations) - 20} more" if len(violations) > 20 else "") +
179
+ "\n\nAdd tests or coverage.exceptions.acceptance_without_tests"
180
+ )
181
+ else:
182
+ for violation in violations[:10]:
183
+ emit_coverage_warning(
184
+ "COVERAGE-TEST-3.1",
185
+ f"Acceptance without test: {violation}",
186
+ CoveragePhase.PLANNER_TESTER_ENFORCEMENT
187
+ )
188
+
189
+
190
+ # ============================================================================
191
+ # COVERAGE-TEST-3.2: Contract <-> Wagon Coverage
192
+ # ============================================================================
193
+
194
+
195
+ @pytest.mark.tester
196
+ def test_all_contracts_referenced(wagon_manifests, coverage_exceptions):
197
+ """
198
+ COVERAGE-TEST-3.2a: Every contract schema referenced by wagon.
199
+
200
+ Given: Contract JSON files in contracts/
201
+ When: Checking wagon produce/consume references
202
+ Then: Every contract is referenced by at least one wagon
203
+ """
204
+ if not CONTRACTS_DIR.exists():
205
+ pytest.skip("contracts/ directory does not exist")
206
+
207
+ allowed_contracts = set(coverage_exceptions.get("contracts_unreferenced", []))
208
+
209
+ # Build set of all contract references from wagons
210
+ wagon_contract_refs: Set[str] = set()
211
+
212
+ for path, manifest in wagon_manifests:
213
+ for produce_item in manifest.get("produce", []):
214
+ contract = produce_item.get("contract")
215
+ if contract:
216
+ wagon_contract_refs.add(contract)
217
+
218
+ for consume_item in manifest.get("consume", []):
219
+ contract = consume_item.get("contract")
220
+ if contract:
221
+ wagon_contract_refs.add(contract)
222
+
223
+ # Find all contract files
224
+ violations = []
225
+
226
+ for contract_file in CONTRACTS_DIR.rglob("*.json"):
227
+ # Skip non-schema files
228
+ if contract_file.name.startswith("_"):
229
+ continue
230
+
231
+ # Check status
232
+ status = get_contract_status(contract_file)
233
+ if status in ("draft", "external", "deprecated"):
234
+ continue
235
+
236
+ # Build contract URN from path
237
+ relative_path = contract_file.relative_to(CONTRACTS_DIR)
238
+ # contracts/domain/resource.json -> contract:domain:resource
239
+ parts = list(relative_path.parts)
240
+ if len(parts) >= 2:
241
+ domain = parts[0]
242
+ resource = parts[-1].replace(".json", "")
243
+ # Handle nested paths (category)
244
+ if len(parts) > 2:
245
+ category = "/".join(parts[1:-1])
246
+ resource = f"{category}/{resource}"
247
+
248
+ contract_urn = f"contract:{domain}:{resource}"
249
+
250
+ # Skip allowed exceptions
251
+ if contract_urn in allowed_contracts:
252
+ continue
253
+
254
+ # Check if referenced
255
+ is_referenced = any(
256
+ contract_urn in ref or ref.endswith(f":{resource}")
257
+ for ref in wagon_contract_refs
258
+ )
259
+
260
+ if not is_referenced:
261
+ violations.append(
262
+ f"{contract_file.relative_to(REPO_ROOT)}: not referenced by any wagon"
263
+ )
264
+
265
+ if violations:
266
+ if should_enforce(CoveragePhase.PLANNER_TESTER_ENFORCEMENT):
267
+ pytest.fail(
268
+ f"COVERAGE-TEST-3.2a: Contracts not referenced:\n " +
269
+ "\n ".join(violations[:20]) +
270
+ (f"\n ... and {len(violations) - 20} more" if len(violations) > 20 else "") +
271
+ "\n\nAdd to wagon produce/consume or coverage.exceptions.contracts_unreferenced"
272
+ )
273
+ else:
274
+ for violation in violations[:10]:
275
+ emit_coverage_warning(
276
+ "COVERAGE-TEST-3.2a",
277
+ violation,
278
+ CoveragePhase.PLANNER_TESTER_ENFORCEMENT
279
+ )
280
+
281
+
282
+ @pytest.mark.tester
283
+ def test_all_contract_refs_exist(wagon_manifests):
284
+ """
285
+ COVERAGE-TEST-3.2b: Every wagon contract ref has schema file.
286
+
287
+ Given: Wagon produce/consume with contract fields
288
+ When: Checking for corresponding files
289
+ Then: Every contract reference has a schema file
290
+ """
291
+ if not CONTRACTS_DIR.exists():
292
+ pytest.skip("contracts/ directory does not exist")
293
+
294
+ violations = []
295
+
296
+ for path, manifest in wagon_manifests:
297
+ wagon_slug = manifest.get("wagon", path.parent.name)
298
+
299
+ all_contract_refs = []
300
+
301
+ for produce_item in manifest.get("produce", []):
302
+ contract = produce_item.get("contract")
303
+ if contract:
304
+ all_contract_refs.append((contract, "produce"))
305
+
306
+ for consume_item in manifest.get("consume", []):
307
+ contract = consume_item.get("contract")
308
+ if contract:
309
+ all_contract_refs.append((contract, "consume"))
310
+
311
+ for contract_ref, ref_type in all_contract_refs:
312
+ # Parse contract URN: contract:domain:resource
313
+ if contract_ref.startswith("contract:"):
314
+ parts = contract_ref.split(":")
315
+ if len(parts) >= 3:
316
+ domain = parts[1]
317
+ resource = ":".join(parts[2:]) # Handle nested resources
318
+
319
+ # Try to find the contract file
320
+ contract_path = CONTRACTS_DIR / domain / f"{resource}.json"
321
+ contract_path_nested = CONTRACTS_DIR / domain / resource / "index.json"
322
+
323
+ if not contract_path.exists() and not contract_path_nested.exists():
324
+ violations.append(
325
+ f"{wagon_slug}: {ref_type} contract '{contract_ref}' - file not found"
326
+ )
327
+
328
+ if violations:
329
+ if should_enforce(CoveragePhase.PLANNER_TESTER_ENFORCEMENT):
330
+ pytest.fail(
331
+ f"COVERAGE-TEST-3.2b: Contract references without files:\n " +
332
+ "\n ".join(violations)
333
+ )
334
+ else:
335
+ for violation in violations:
336
+ emit_coverage_warning(
337
+ "COVERAGE-TEST-3.2b",
338
+ violation,
339
+ CoveragePhase.PLANNER_TESTER_ENFORCEMENT
340
+ )
341
+
342
+
343
+ # ============================================================================
344
+ # COVERAGE-TEST-3.3: Telemetry <-> Wagon Coverage
345
+ # ============================================================================
346
+
347
+
348
+ @pytest.mark.tester
349
+ def test_all_telemetry_referenced(wagon_manifests, coverage_exceptions):
350
+ """
351
+ COVERAGE-TEST-3.3a: Every telemetry signal referenced by wagon.
352
+
353
+ Given: Telemetry signal files in telemetry/
354
+ When: Checking wagon produce references
355
+ Then: Every telemetry signal is referenced by at least one wagon
356
+ """
357
+ if not TELEMETRY_DIR.exists():
358
+ pytest.skip("telemetry/ directory does not exist")
359
+
360
+ allowed_telemetry = set(coverage_exceptions.get("telemetry_unreferenced", []))
361
+
362
+ # Build set of all telemetry references from wagons
363
+ wagon_telemetry_refs: Set[str] = set()
364
+
365
+ for path, manifest in wagon_manifests:
366
+ for produce_item in manifest.get("produce", []):
367
+ telemetry = produce_item.get("telemetry")
368
+ if telemetry:
369
+ if isinstance(telemetry, list):
370
+ wagon_telemetry_refs.update(telemetry)
371
+ else:
372
+ wagon_telemetry_refs.add(telemetry)
373
+
374
+ # Find all telemetry directories (each dir represents a telemetry URN)
375
+ violations = []
376
+
377
+ for domain_dir in TELEMETRY_DIR.iterdir():
378
+ if not domain_dir.is_dir() or domain_dir.name.startswith("_"):
379
+ continue
380
+
381
+ for resource_dir in domain_dir.iterdir():
382
+ if not resource_dir.is_dir():
383
+ continue
384
+
385
+ # Check for signal files
386
+ signal_files = list(resource_dir.glob("*.json"))
387
+ if not signal_files:
388
+ continue
389
+
390
+ # Check status of first signal
391
+ status = get_telemetry_signal_status(signal_files[0])
392
+ if status in ("draft", "external", "deprecated"):
393
+ continue
394
+
395
+ # Build telemetry URN
396
+ telemetry_urn = f"telemetry:{domain_dir.name}:{resource_dir.name}"
397
+
398
+ # Skip allowed exceptions
399
+ if telemetry_urn in allowed_telemetry:
400
+ continue
401
+
402
+ # Check if referenced
403
+ is_referenced = any(
404
+ telemetry_urn in ref or ref.endswith(f":{resource_dir.name}")
405
+ for ref in wagon_telemetry_refs
406
+ )
407
+
408
+ if not is_referenced:
409
+ violations.append(
410
+ f"{resource_dir.relative_to(REPO_ROOT)}: not referenced by any wagon"
411
+ )
412
+
413
+ if violations:
414
+ if should_enforce(CoveragePhase.PLANNER_TESTER_ENFORCEMENT):
415
+ pytest.fail(
416
+ f"COVERAGE-TEST-3.3a: Telemetry not referenced:\n " +
417
+ "\n ".join(violations[:20]) +
418
+ (f"\n ... and {len(violations) - 20} more" if len(violations) > 20 else "") +
419
+ "\n\nAdd to wagon produce or coverage.exceptions.telemetry_unreferenced"
420
+ )
421
+ else:
422
+ for violation in violations[:10]:
423
+ emit_coverage_warning(
424
+ "COVERAGE-TEST-3.3a",
425
+ violation,
426
+ CoveragePhase.PLANNER_TESTER_ENFORCEMENT
427
+ )
428
+
429
+
430
+ @pytest.mark.tester
431
+ def test_all_telemetry_refs_exist(wagon_manifests):
432
+ """
433
+ COVERAGE-TEST-3.3b: Every wagon telemetry ref has signal files.
434
+
435
+ Given: Wagon produce with telemetry fields
436
+ When: Checking for corresponding directories
437
+ Then: Every telemetry reference has signal files
438
+ """
439
+ if not TELEMETRY_DIR.exists():
440
+ pytest.skip("telemetry/ directory does not exist")
441
+
442
+ violations = []
443
+
444
+ for path, manifest in wagon_manifests:
445
+ wagon_slug = manifest.get("wagon", path.parent.name)
446
+
447
+ for produce_item in manifest.get("produce", []):
448
+ telemetry = produce_item.get("telemetry")
449
+ if not telemetry:
450
+ continue
451
+
452
+ telemetry_refs = telemetry if isinstance(telemetry, list) else [telemetry]
453
+
454
+ for telemetry_ref in telemetry_refs:
455
+ # Parse telemetry URN: telemetry:domain:resource[.category]
456
+ if telemetry_ref.startswith("telemetry:"):
457
+ parts = telemetry_ref.split(":")
458
+ if len(parts) >= 3:
459
+ domain = parts[1]
460
+ resource = parts[2]
461
+
462
+ # Handle category suffix (resource.category)
463
+ if "." in resource:
464
+ resource_parts = resource.split(".")
465
+ resource = resource_parts[0]
466
+ category = "/".join(resource_parts[1:])
467
+ telemetry_path = TELEMETRY_DIR / domain / resource / category
468
+ else:
469
+ telemetry_path = TELEMETRY_DIR / domain / resource
470
+
471
+ # Check if directory exists with signal files
472
+ if not telemetry_path.exists():
473
+ violations.append(
474
+ f"{wagon_slug}: telemetry '{telemetry_ref}' - directory not found"
475
+ )
476
+ elif not list(telemetry_path.glob("*.json")):
477
+ violations.append(
478
+ f"{wagon_slug}: telemetry '{telemetry_ref}' - no signal files"
479
+ )
480
+
481
+ if violations:
482
+ if should_enforce(CoveragePhase.PLANNER_TESTER_ENFORCEMENT):
483
+ pytest.fail(
484
+ f"COVERAGE-TEST-3.3b: Telemetry references without files:\n " +
485
+ "\n ".join(violations)
486
+ )
487
+ else:
488
+ for violation in violations:
489
+ emit_coverage_warning(
490
+ "COVERAGE-TEST-3.3b",
491
+ violation,
492
+ CoveragePhase.PLANNER_TESTER_ENFORCEMENT
493
+ )
494
+
495
+
496
+ # ============================================================================
497
+ # COVERAGE-TEST-3.4: Telemetry Tracking Manifest
498
+ # ============================================================================
499
+
500
+
501
+ @pytest.mark.tester
502
+ def test_telemetry_manifest_complete():
503
+ """
504
+ COVERAGE-TEST-3.4: Tracking manifest signals all exist as files.
505
+
506
+ Given: telemetry/_tracking_manifest.yaml if present
507
+ When: Checking listed signals
508
+ Then: All signals in manifest have corresponding files
509
+ """
510
+ manifest_path = TELEMETRY_DIR / "_tracking_manifest.yaml"
511
+
512
+ if not manifest_path.exists():
513
+ pytest.skip("No telemetry tracking manifest found")
514
+
515
+ try:
516
+ with open(manifest_path) as f:
517
+ manifest_data = yaml.safe_load(f)
518
+ except Exception as e:
519
+ pytest.fail(f"Failed to load tracking manifest: {e}")
520
+
521
+ violations = []
522
+
523
+ signals = manifest_data.get("signals", [])
524
+ for signal_entry in signals:
525
+ if isinstance(signal_entry, dict):
526
+ signal_path = signal_entry.get("path")
527
+ signal_urn = signal_entry.get("urn")
528
+ else:
529
+ signal_path = signal_entry
530
+ signal_urn = signal_entry
531
+
532
+ if signal_path:
533
+ full_path = TELEMETRY_DIR / signal_path
534
+ if not full_path.exists():
535
+ violations.append(f"{signal_urn or signal_path}: file not found")
536
+
537
+ if violations:
538
+ if should_enforce(CoveragePhase.PLANNER_TESTER_ENFORCEMENT):
539
+ pytest.fail(
540
+ f"COVERAGE-TEST-3.4: Tracking manifest signals missing:\n " +
541
+ "\n ".join(violations)
542
+ )
543
+ else:
544
+ for violation in violations:
545
+ emit_coverage_warning(
546
+ "COVERAGE-TEST-3.4",
547
+ violation,
548
+ CoveragePhase.PLANNER_TESTER_ENFORCEMENT
549
+ )
550
+
551
+
552
+ # ============================================================================
553
+ # COVERAGE SUMMARY
554
+ # ============================================================================
555
+
556
+
557
+ @pytest.mark.tester
558
+ def test_tester_coverage_summary(
559
+ all_acceptance_urns,
560
+ wagon_manifests,
561
+ coverage_thresholds
562
+ ):
563
+ """
564
+ COVERAGE-TEST-SUMMARY: Report tester coverage statistics.
565
+
566
+ This test always passes but reports coverage metrics for visibility.
567
+ """
568
+ # Count acceptance coverage
569
+ test_references = find_acceptance_references_in_tests()
570
+ test_references_lower = {urn.lower() for urn in test_references}
571
+
572
+ covered_acceptances = sum(
573
+ 1 for urn in all_acceptance_urns
574
+ if urn.lower() in test_references_lower
575
+ )
576
+ total_acceptances = len(all_acceptance_urns)
577
+
578
+ # Calculate coverage percentage
579
+ coverage_pct = (covered_acceptances / total_acceptances * 100) if total_acceptances > 0 else 0
580
+ threshold = coverage_thresholds.get("min_acceptance_coverage", 80)
581
+
582
+ # Count contracts
583
+ total_contracts = 0
584
+ if CONTRACTS_DIR.exists():
585
+ total_contracts = len(list(CONTRACTS_DIR.rglob("*.json")))
586
+
587
+ # Count telemetry
588
+ total_telemetry = 0
589
+ if TELEMETRY_DIR.exists():
590
+ for domain_dir in TELEMETRY_DIR.iterdir():
591
+ if domain_dir.is_dir() and not domain_dir.name.startswith("_"):
592
+ total_telemetry += sum(1 for _ in domain_dir.iterdir() if _.is_dir())
593
+
594
+ # Report summary
595
+ summary = (
596
+ f"\n\nTester Coverage Summary:\n"
597
+ f" Acceptances covered: {covered_acceptances}/{total_acceptances} ({coverage_pct:.1f}%)\n"
598
+ f" Coverage threshold: {threshold}%\n"
599
+ f" Total contracts: {total_contracts}\n"
600
+ f" Total telemetry domains: {total_telemetry}"
601
+ )
602
+
603
+ # This test always passes - it's informational
604
+ assert True, summary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atdd
3
- Version: 0.4.7
3
+ Version: 0.6.0
4
4
  Summary: ATDD Platform - Acceptance Test Driven Development toolkit
5
5
  License: MIT
6
6
  Requires-Python: >=3.10