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
|
@@ -7,13 +7,27 @@ Validates that trains follow conventions:
|
|
|
7
7
|
- Artifact consistency
|
|
8
8
|
- Dependencies are valid
|
|
9
9
|
- Registry grouping matches numbering
|
|
10
|
+
- Train First-Class Spec v0.6 requirements
|
|
11
|
+
|
|
12
|
+
Train First-Class Spec v0.6 validators (SPEC-TRAIN-VAL-0012 to 0021, 0034-0036):
|
|
13
|
+
- Path/file normalization
|
|
14
|
+
- Theme derivation and validation
|
|
15
|
+
- Wagon participant validation
|
|
16
|
+
- Test/code field typing
|
|
17
|
+
- Expectations and status inference
|
|
10
18
|
"""
|
|
11
19
|
import pytest
|
|
12
20
|
import yaml
|
|
21
|
+
import warnings
|
|
13
22
|
from pathlib import Path
|
|
14
|
-
from typing import Dict, List, Set, Tuple
|
|
23
|
+
from typing import Dict, List, Set, Tuple, Any, Optional
|
|
15
24
|
|
|
16
25
|
from atdd.coach.utils.repo import find_repo_root
|
|
26
|
+
from atdd.coach.utils.train_spec_phase import (
|
|
27
|
+
TrainSpecPhase,
|
|
28
|
+
should_enforce,
|
|
29
|
+
emit_phase_warning
|
|
30
|
+
)
|
|
17
31
|
|
|
18
32
|
|
|
19
33
|
@pytest.mark.platform
|
|
@@ -25,10 +39,13 @@ def test_train_ids_follow_numbering_convention(trains_registry):
|
|
|
25
39
|
When: Checking train_id format
|
|
26
40
|
Then: Each train_id matches pattern: {digit}{digit}{digit}{digit}-{kebab-case-name}
|
|
27
41
|
(4-digit hierarchical: [Theme][Category][Variation])
|
|
42
|
+
|
|
43
|
+
Updated in v0.6: Pattern now allows digits in slug portion (^\\d{4}-[a-z0-9-]+$)
|
|
28
44
|
"""
|
|
29
45
|
import re
|
|
30
46
|
|
|
31
|
-
pattern
|
|
47
|
+
# v0.6: Updated pattern to allow digits in slug (e.g., 0001-auth-v2-session)
|
|
48
|
+
pattern = re.compile(r"^\d{4}-[a-z0-9-]+$")
|
|
32
49
|
|
|
33
50
|
for theme, trains in trains_registry.items():
|
|
34
51
|
if not trains:
|
|
@@ -514,3 +531,651 @@ def test_trains_match_schema(trains_registry):
|
|
|
514
531
|
|
|
515
532
|
assert not failures, \
|
|
516
533
|
f"Schema validation failures:\n " + "\n ".join(failures)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# ============================================================================
|
|
537
|
+
# TRAIN FIRST-CLASS SPEC v0.6 VALIDATORS
|
|
538
|
+
# ============================================================================
|
|
539
|
+
# New validators for path/file normalization, theme validation, wagon
|
|
540
|
+
# participants, test/code field typing, and expectations/status inference.
|
|
541
|
+
# ============================================================================
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _normalize_test_code_field(field_value: Any) -> Dict[str, List[str]]:
|
|
545
|
+
"""
|
|
546
|
+
Normalize test/code field to canonical structure.
|
|
547
|
+
|
|
548
|
+
Section 5: Test/Code Field Typing Normalization
|
|
549
|
+
- string -> {"backend": [string]}
|
|
550
|
+
- list -> {"backend": list}
|
|
551
|
+
- dict -> normalize each sub-field to list
|
|
552
|
+
"""
|
|
553
|
+
if field_value is None:
|
|
554
|
+
return {}
|
|
555
|
+
|
|
556
|
+
if isinstance(field_value, str):
|
|
557
|
+
return {"backend": [field_value]}
|
|
558
|
+
elif isinstance(field_value, list):
|
|
559
|
+
return {"backend": field_value}
|
|
560
|
+
elif isinstance(field_value, dict):
|
|
561
|
+
result = {}
|
|
562
|
+
for key in ["backend", "frontend", "frontend_python"]:
|
|
563
|
+
if key in field_value:
|
|
564
|
+
val = field_value[key]
|
|
565
|
+
result[key] = [val] if isinstance(val, str) else (val or [])
|
|
566
|
+
return result
|
|
567
|
+
return {}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _infer_expectations(train_data: Dict[str, Any]) -> Dict[str, bool]:
|
|
571
|
+
"""
|
|
572
|
+
Infer expectations from train data.
|
|
573
|
+
|
|
574
|
+
Section 12: Status/Expectations Inference
|
|
575
|
+
"""
|
|
576
|
+
if "expectations" in train_data:
|
|
577
|
+
return train_data["expectations"]
|
|
578
|
+
|
|
579
|
+
status = train_data.get("status", "planned")
|
|
580
|
+
test_fields = _normalize_test_code_field(train_data.get("test"))
|
|
581
|
+
code_fields = _normalize_test_code_field(train_data.get("code"))
|
|
582
|
+
|
|
583
|
+
if status == "tested":
|
|
584
|
+
return {"backend": True}
|
|
585
|
+
elif status == "implemented":
|
|
586
|
+
return {
|
|
587
|
+
"backend": True,
|
|
588
|
+
"frontend": bool(code_fields.get("frontend")),
|
|
589
|
+
"frontend_python": bool(code_fields.get("frontend_python"))
|
|
590
|
+
}
|
|
591
|
+
else:
|
|
592
|
+
return {
|
|
593
|
+
"backend": bool(test_fields.get("backend") or code_fields.get("backend")),
|
|
594
|
+
"frontend": bool(test_fields.get("frontend") or code_fields.get("frontend")),
|
|
595
|
+
"frontend_python": bool(test_fields.get("frontend_python") or code_fields.get("frontend_python"))
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _extract_wagons_from_participants(participants: List[str]) -> List[str]:
|
|
600
|
+
"""
|
|
601
|
+
Extract wagon names from participants list.
|
|
602
|
+
|
|
603
|
+
Section 4: Participants is Canonical Wagon Source
|
|
604
|
+
"""
|
|
605
|
+
wagons = []
|
|
606
|
+
for participant in participants:
|
|
607
|
+
if isinstance(participant, str) and participant.startswith("wagon:"):
|
|
608
|
+
wagon_name = participant.replace("wagon:", "")
|
|
609
|
+
wagons.append(wagon_name)
|
|
610
|
+
return wagons
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@pytest.mark.platform
|
|
614
|
+
def test_train_path_file_normalization(trains_registry):
|
|
615
|
+
"""
|
|
616
|
+
SPEC-TRAIN-VAL-0012: Path is canonical, file is deprecated alias
|
|
617
|
+
|
|
618
|
+
Given: Trains in registry
|
|
619
|
+
When: Checking path/file fields
|
|
620
|
+
Then: If only 'file' exists without 'path', emit deprecation warning
|
|
621
|
+
|
|
622
|
+
Section 1: Path Canonical, File Deprecated Alias
|
|
623
|
+
"""
|
|
624
|
+
repo_root = find_repo_root()
|
|
625
|
+
trains_dir = repo_root / "plan" / "_trains"
|
|
626
|
+
|
|
627
|
+
deprecation_warnings = []
|
|
628
|
+
|
|
629
|
+
for theme, trains in trains_registry.items():
|
|
630
|
+
if not trains:
|
|
631
|
+
continue
|
|
632
|
+
|
|
633
|
+
for train in trains:
|
|
634
|
+
train_id = train.get("train_id", "")
|
|
635
|
+
if not train_id:
|
|
636
|
+
continue
|
|
637
|
+
|
|
638
|
+
train_path = trains_dir / f"{train_id}.yaml"
|
|
639
|
+
if not train_path.exists():
|
|
640
|
+
continue
|
|
641
|
+
|
|
642
|
+
with train_path.open() as f:
|
|
643
|
+
train_data = yaml.safe_load(f)
|
|
644
|
+
|
|
645
|
+
has_path = "path" in train_data
|
|
646
|
+
has_file = "file" in train_data
|
|
647
|
+
|
|
648
|
+
if has_file and not has_path:
|
|
649
|
+
deprecation_warnings.append(
|
|
650
|
+
f"{train_id}: uses 'file' without 'path' (deprecated)"
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
if deprecation_warnings:
|
|
654
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
655
|
+
pytest.fail(
|
|
656
|
+
f"Trains using deprecated 'file' field:\n " +
|
|
657
|
+
"\n ".join(deprecation_warnings) +
|
|
658
|
+
"\n\nMigrate to 'path' field (Section 1 of Train First-Class Spec)"
|
|
659
|
+
)
|
|
660
|
+
else:
|
|
661
|
+
for warning in deprecation_warnings:
|
|
662
|
+
emit_phase_warning(
|
|
663
|
+
"SPEC-TRAIN-VAL-0012",
|
|
664
|
+
warning,
|
|
665
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@pytest.mark.platform
|
|
670
|
+
def test_train_ids_globally_unique(trains_registry):
|
|
671
|
+
"""
|
|
672
|
+
SPEC-TRAIN-VAL-0013: Train IDs globally unique across categories
|
|
673
|
+
|
|
674
|
+
Given: Trains across all themes and categories
|
|
675
|
+
When: Checking train_id uniqueness
|
|
676
|
+
Then: No duplicate train_ids exist
|
|
677
|
+
|
|
678
|
+
Section 2: Global Uniqueness
|
|
679
|
+
"""
|
|
680
|
+
all_train_ids = []
|
|
681
|
+
train_locations = {}
|
|
682
|
+
|
|
683
|
+
for theme, trains in trains_registry.items():
|
|
684
|
+
if not trains:
|
|
685
|
+
continue
|
|
686
|
+
|
|
687
|
+
for train in trains:
|
|
688
|
+
train_id = train.get("train_id", "")
|
|
689
|
+
if train_id:
|
|
690
|
+
all_train_ids.append(train_id)
|
|
691
|
+
if train_id in train_locations:
|
|
692
|
+
train_locations[train_id].append(theme)
|
|
693
|
+
else:
|
|
694
|
+
train_locations[train_id] = [theme]
|
|
695
|
+
|
|
696
|
+
duplicates = {
|
|
697
|
+
tid: themes for tid, themes in train_locations.items()
|
|
698
|
+
if len(themes) > 1
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
assert not duplicates, \
|
|
702
|
+
f"Duplicate train IDs found:\n " + \
|
|
703
|
+
"\n ".join(f"{tid}: appears in themes {themes}" for tid, themes in duplicates.items())
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
@pytest.mark.platform
|
|
707
|
+
def test_train_theme_derived_from_group_key(trains_registry_with_groups):
|
|
708
|
+
"""
|
|
709
|
+
SPEC-TRAIN-VAL-0014: Theme derived from registry group key
|
|
710
|
+
|
|
711
|
+
Given: Trains in nested registry structure
|
|
712
|
+
When: Checking theme derivation
|
|
713
|
+
Then: Theme can be derived from group key (e.g., "0-commons" -> "commons")
|
|
714
|
+
|
|
715
|
+
Section 3: Theme Precedence Rules
|
|
716
|
+
"""
|
|
717
|
+
for theme_key, categories in trains_registry_with_groups.items():
|
|
718
|
+
if not isinstance(categories, dict):
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
# Derive theme from group key
|
|
722
|
+
if "-" in theme_key:
|
|
723
|
+
derived_theme = theme_key.split("-", 1)[1]
|
|
724
|
+
else:
|
|
725
|
+
derived_theme = theme_key
|
|
726
|
+
|
|
727
|
+
# Verify derived theme is valid
|
|
728
|
+
valid_themes = {
|
|
729
|
+
"commons", "mechanic", "scenario", "match", "sensory",
|
|
730
|
+
"player", "league", "audience", "monetization", "partnership"
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
assert derived_theme in valid_themes, \
|
|
734
|
+
f"Invalid derived theme '{derived_theme}' from group key '{theme_key}'"
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
@pytest.mark.platform
|
|
738
|
+
def test_train_explicit_theme_matches_group(trains_registry_with_groups):
|
|
739
|
+
"""
|
|
740
|
+
SPEC-TRAIN-VAL-0015: Explicit theme matches group placement
|
|
741
|
+
|
|
742
|
+
Given: Trains with explicit theme field in registry
|
|
743
|
+
When: Checking theme consistency
|
|
744
|
+
Then: Explicit theme matches derived theme from group key
|
|
745
|
+
|
|
746
|
+
Section 3: Theme Precedence Rules
|
|
747
|
+
"""
|
|
748
|
+
mismatches = []
|
|
749
|
+
|
|
750
|
+
for theme_key, categories in trains_registry_with_groups.items():
|
|
751
|
+
if not isinstance(categories, dict):
|
|
752
|
+
continue
|
|
753
|
+
|
|
754
|
+
# Derive theme from group key
|
|
755
|
+
derived_theme = theme_key.split("-", 1)[1] if "-" in theme_key else theme_key
|
|
756
|
+
|
|
757
|
+
for category_key, trains_list in categories.items():
|
|
758
|
+
if not isinstance(trains_list, list):
|
|
759
|
+
continue
|
|
760
|
+
|
|
761
|
+
for train in trains_list:
|
|
762
|
+
train_id = train.get("train_id", "unknown")
|
|
763
|
+
explicit_theme = train.get("theme")
|
|
764
|
+
|
|
765
|
+
if explicit_theme and explicit_theme != derived_theme:
|
|
766
|
+
mismatches.append(
|
|
767
|
+
f"{train_id}: explicit theme '{explicit_theme}' != "
|
|
768
|
+
f"derived theme '{derived_theme}' (group: {theme_key})"
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
if mismatches:
|
|
772
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
773
|
+
pytest.fail(
|
|
774
|
+
f"Theme mismatches found:\n " + "\n ".join(mismatches)
|
|
775
|
+
)
|
|
776
|
+
else:
|
|
777
|
+
for mismatch in mismatches:
|
|
778
|
+
emit_phase_warning(
|
|
779
|
+
"SPEC-TRAIN-VAL-0015",
|
|
780
|
+
mismatch,
|
|
781
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
782
|
+
)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@pytest.mark.platform
|
|
786
|
+
def test_train_yaml_themes_include_derived(train_files):
|
|
787
|
+
"""
|
|
788
|
+
SPEC-TRAIN-VAL-0016: YAML themes array includes derived theme
|
|
789
|
+
|
|
790
|
+
Given: Train YAML files with themes array
|
|
791
|
+
When: Comparing to registry placement
|
|
792
|
+
Then: YAML themes array includes the derived theme from registry
|
|
793
|
+
|
|
794
|
+
Section 3: Theme Precedence Rules
|
|
795
|
+
"""
|
|
796
|
+
repo_root = find_repo_root()
|
|
797
|
+
trains_file = repo_root / "plan" / "_trains.yaml"
|
|
798
|
+
|
|
799
|
+
if not trains_file.exists():
|
|
800
|
+
pytest.skip("No _trains.yaml registry found")
|
|
801
|
+
|
|
802
|
+
# Build train -> derived_theme mapping from registry
|
|
803
|
+
with open(trains_file) as f:
|
|
804
|
+
registry_data = yaml.safe_load(f)
|
|
805
|
+
|
|
806
|
+
train_to_theme = {}
|
|
807
|
+
for theme_key, categories in registry_data.get("trains", {}).items():
|
|
808
|
+
derived_theme = theme_key.split("-", 1)[1] if "-" in theme_key else theme_key
|
|
809
|
+
if isinstance(categories, dict):
|
|
810
|
+
for category_key, trains_list in categories.items():
|
|
811
|
+
if isinstance(trains_list, list):
|
|
812
|
+
for train in trains_list:
|
|
813
|
+
train_id = train.get("train_id")
|
|
814
|
+
if train_id:
|
|
815
|
+
train_to_theme[train_id] = derived_theme
|
|
816
|
+
|
|
817
|
+
# Check YAML files
|
|
818
|
+
mismatches = []
|
|
819
|
+
for train_path, train_data in train_files:
|
|
820
|
+
train_id = train_data.get("train_id")
|
|
821
|
+
yaml_themes = train_data.get("themes", [])
|
|
822
|
+
|
|
823
|
+
if train_id in train_to_theme:
|
|
824
|
+
derived_theme = train_to_theme[train_id]
|
|
825
|
+
if yaml_themes and derived_theme not in yaml_themes:
|
|
826
|
+
mismatches.append(
|
|
827
|
+
f"{train_id}: YAML themes {yaml_themes} missing derived theme '{derived_theme}'"
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
if mismatches:
|
|
831
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
832
|
+
pytest.fail(
|
|
833
|
+
f"YAML themes missing derived themes:\n " + "\n ".join(mismatches)
|
|
834
|
+
)
|
|
835
|
+
else:
|
|
836
|
+
for mismatch in mismatches:
|
|
837
|
+
emit_phase_warning(
|
|
838
|
+
"SPEC-TRAIN-VAL-0016",
|
|
839
|
+
mismatch,
|
|
840
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@pytest.mark.platform
|
|
845
|
+
def test_train_participants_canonical_wagon_source(train_files):
|
|
846
|
+
"""
|
|
847
|
+
SPEC-TRAIN-VAL-0017: Participants is canonical wagon source
|
|
848
|
+
|
|
849
|
+
Given: Train YAML files
|
|
850
|
+
When: Extracting wagon references
|
|
851
|
+
Then: Wagons are derived from participants array (wagon:* entries)
|
|
852
|
+
|
|
853
|
+
Section 4: Participants is Canonical Wagon Source
|
|
854
|
+
"""
|
|
855
|
+
missing_participants = []
|
|
856
|
+
|
|
857
|
+
for train_path, train_data in train_files:
|
|
858
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
859
|
+
participants = train_data.get("participants", [])
|
|
860
|
+
|
|
861
|
+
if not participants:
|
|
862
|
+
missing_participants.append(f"{train_id}: no participants defined")
|
|
863
|
+
continue
|
|
864
|
+
|
|
865
|
+
wagons = _extract_wagons_from_participants(participants)
|
|
866
|
+
if not wagons:
|
|
867
|
+
missing_participants.append(f"{train_id}: no wagon participants found")
|
|
868
|
+
|
|
869
|
+
if missing_participants:
|
|
870
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
871
|
+
pytest.fail(
|
|
872
|
+
f"Trains missing wagon participants:\n " + "\n ".join(missing_participants)
|
|
873
|
+
)
|
|
874
|
+
else:
|
|
875
|
+
for missing in missing_participants:
|
|
876
|
+
emit_phase_warning(
|
|
877
|
+
"SPEC-TRAIN-VAL-0017",
|
|
878
|
+
missing,
|
|
879
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
@pytest.mark.platform
|
|
884
|
+
def test_train_registry_wagons_subset_of_yaml(trains_registry, train_files):
|
|
885
|
+
"""
|
|
886
|
+
SPEC-TRAIN-VAL-0018: Registry wagons subset of YAML participants
|
|
887
|
+
|
|
888
|
+
Given: Trains in registry and YAML files
|
|
889
|
+
When: Comparing wagon lists
|
|
890
|
+
Then: Registry wagons must be subset of YAML participants
|
|
891
|
+
|
|
892
|
+
Section 4: Registry Wagons Subset
|
|
893
|
+
"""
|
|
894
|
+
# Build train_id -> YAML wagons mapping
|
|
895
|
+
yaml_wagons = {}
|
|
896
|
+
for train_path, train_data in train_files:
|
|
897
|
+
train_id = train_data.get("train_id")
|
|
898
|
+
if train_id:
|
|
899
|
+
participants = train_data.get("participants", [])
|
|
900
|
+
yaml_wagons[train_id] = set(_extract_wagons_from_participants(participants))
|
|
901
|
+
|
|
902
|
+
violations = []
|
|
903
|
+
|
|
904
|
+
for theme, trains in trains_registry.items():
|
|
905
|
+
if not trains:
|
|
906
|
+
continue
|
|
907
|
+
|
|
908
|
+
for train in trains:
|
|
909
|
+
train_id = train.get("train_id", "")
|
|
910
|
+
registry_wagon_list = train.get("wagons", [])
|
|
911
|
+
|
|
912
|
+
if not train_id or not registry_wagon_list:
|
|
913
|
+
continue
|
|
914
|
+
|
|
915
|
+
yaml_wagon_set = yaml_wagons.get(train_id, set())
|
|
916
|
+
registry_wagon_set = set(registry_wagon_list)
|
|
917
|
+
|
|
918
|
+
extra_wagons = registry_wagon_set - yaml_wagon_set
|
|
919
|
+
if extra_wagons:
|
|
920
|
+
violations.append(
|
|
921
|
+
f"{train_id}: registry wagons {extra_wagons} not in YAML participants"
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
if violations:
|
|
925
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
926
|
+
pytest.fail(
|
|
927
|
+
f"Registry wagons not in YAML participants:\n " + "\n ".join(violations)
|
|
928
|
+
)
|
|
929
|
+
else:
|
|
930
|
+
for violation in violations:
|
|
931
|
+
emit_phase_warning(
|
|
932
|
+
"SPEC-TRAIN-VAL-0018",
|
|
933
|
+
violation,
|
|
934
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
@pytest.mark.platform
|
|
939
|
+
def test_train_primary_wagon_in_participants(train_files):
|
|
940
|
+
"""
|
|
941
|
+
SPEC-TRAIN-VAL-0019: Primary wagon exists and is in participants
|
|
942
|
+
|
|
943
|
+
Given: Train YAML files with primary_wagon field
|
|
944
|
+
When: Checking primary wagon
|
|
945
|
+
Then: Primary wagon is in participants list
|
|
946
|
+
|
|
947
|
+
Section 4: Primary Wagon Validation
|
|
948
|
+
"""
|
|
949
|
+
violations = []
|
|
950
|
+
|
|
951
|
+
for train_path, train_data in train_files:
|
|
952
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
953
|
+
primary_wagon = train_data.get("primary_wagon")
|
|
954
|
+
|
|
955
|
+
if not primary_wagon:
|
|
956
|
+
continue
|
|
957
|
+
|
|
958
|
+
participants = train_data.get("participants", [])
|
|
959
|
+
wagons = _extract_wagons_from_participants(participants)
|
|
960
|
+
|
|
961
|
+
if primary_wagon not in wagons:
|
|
962
|
+
violations.append(
|
|
963
|
+
f"{train_id}: primary_wagon '{primary_wagon}' not in participants"
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
if violations:
|
|
967
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
968
|
+
pytest.fail(
|
|
969
|
+
f"Primary wagon violations:\n " + "\n ".join(violations)
|
|
970
|
+
)
|
|
971
|
+
else:
|
|
972
|
+
for violation in violations:
|
|
973
|
+
emit_phase_warning(
|
|
974
|
+
"SPEC-TRAIN-VAL-0019",
|
|
975
|
+
violation,
|
|
976
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
@pytest.mark.platform
|
|
981
|
+
def test_train_test_field_typing(train_files):
|
|
982
|
+
"""
|
|
983
|
+
SPEC-TRAIN-VAL-0020: Test field typing normalization
|
|
984
|
+
|
|
985
|
+
Given: Train YAML files with test field
|
|
986
|
+
When: Checking test field structure
|
|
987
|
+
Then: Test field normalizes to {backend: [], frontend: [], frontend_python: []}
|
|
988
|
+
|
|
989
|
+
Section 5: Test Field Typing
|
|
990
|
+
"""
|
|
991
|
+
invalid_types = []
|
|
992
|
+
|
|
993
|
+
for train_path, train_data in train_files:
|
|
994
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
995
|
+
test_field = train_data.get("test")
|
|
996
|
+
|
|
997
|
+
if test_field is None:
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
# Validate type
|
|
1001
|
+
if not isinstance(test_field, (str, list, dict)):
|
|
1002
|
+
invalid_types.append(
|
|
1003
|
+
f"{train_id}: test field has invalid type {type(test_field).__name__}"
|
|
1004
|
+
)
|
|
1005
|
+
continue
|
|
1006
|
+
|
|
1007
|
+
# Validate dict structure if applicable
|
|
1008
|
+
if isinstance(test_field, dict):
|
|
1009
|
+
valid_keys = {"backend", "frontend", "frontend_python"}
|
|
1010
|
+
extra_keys = set(test_field.keys()) - valid_keys
|
|
1011
|
+
if extra_keys:
|
|
1012
|
+
invalid_types.append(
|
|
1013
|
+
f"{train_id}: test field has invalid keys {extra_keys}"
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
assert not invalid_types, \
|
|
1017
|
+
f"Invalid test field types:\n " + "\n ".join(invalid_types)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@pytest.mark.platform
|
|
1021
|
+
def test_train_code_field_typing(train_files):
|
|
1022
|
+
"""
|
|
1023
|
+
SPEC-TRAIN-VAL-0021: Code field typing normalization
|
|
1024
|
+
|
|
1025
|
+
Given: Train YAML files with code field
|
|
1026
|
+
When: Checking code field structure
|
|
1027
|
+
Then: Code field normalizes to {backend: [], frontend: [], frontend_python: []}
|
|
1028
|
+
|
|
1029
|
+
Section 5: Code Field Typing
|
|
1030
|
+
"""
|
|
1031
|
+
invalid_types = []
|
|
1032
|
+
|
|
1033
|
+
for train_path, train_data in train_files:
|
|
1034
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
1035
|
+
code_field = train_data.get("code")
|
|
1036
|
+
|
|
1037
|
+
if code_field is None:
|
|
1038
|
+
continue
|
|
1039
|
+
|
|
1040
|
+
# Validate type
|
|
1041
|
+
if not isinstance(code_field, (str, list, dict)):
|
|
1042
|
+
invalid_types.append(
|
|
1043
|
+
f"{train_id}: code field has invalid type {type(code_field).__name__}"
|
|
1044
|
+
)
|
|
1045
|
+
continue
|
|
1046
|
+
|
|
1047
|
+
# Validate dict structure if applicable
|
|
1048
|
+
if isinstance(code_field, dict):
|
|
1049
|
+
valid_keys = {"backend", "frontend", "frontend_python"}
|
|
1050
|
+
extra_keys = set(code_field.keys()) - valid_keys
|
|
1051
|
+
if extra_keys:
|
|
1052
|
+
invalid_types.append(
|
|
1053
|
+
f"{train_id}: code field has invalid keys {extra_keys}"
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
assert not invalid_types, \
|
|
1057
|
+
f"Invalid code field types:\n " + "\n ".join(invalid_types)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@pytest.mark.platform
|
|
1061
|
+
def test_train_expectations_structure(train_files):
|
|
1062
|
+
"""
|
|
1063
|
+
SPEC-TRAIN-VAL-0034: Expectations field structure
|
|
1064
|
+
|
|
1065
|
+
Given: Train YAML files with expectations field
|
|
1066
|
+
When: Checking expectations structure
|
|
1067
|
+
Then: Expectations has boolean values for backend/frontend/frontend_python
|
|
1068
|
+
|
|
1069
|
+
Section 12: Expectations Field Structure
|
|
1070
|
+
"""
|
|
1071
|
+
invalid_expectations = []
|
|
1072
|
+
|
|
1073
|
+
for train_path, train_data in train_files:
|
|
1074
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
1075
|
+
expectations = train_data.get("expectations")
|
|
1076
|
+
|
|
1077
|
+
if expectations is None:
|
|
1078
|
+
continue
|
|
1079
|
+
|
|
1080
|
+
if not isinstance(expectations, dict):
|
|
1081
|
+
invalid_expectations.append(
|
|
1082
|
+
f"{train_id}: expectations must be object, got {type(expectations).__name__}"
|
|
1083
|
+
)
|
|
1084
|
+
continue
|
|
1085
|
+
|
|
1086
|
+
# Validate keys and types
|
|
1087
|
+
valid_keys = {"backend", "frontend", "frontend_python"}
|
|
1088
|
+
for key, value in expectations.items():
|
|
1089
|
+
if key not in valid_keys:
|
|
1090
|
+
invalid_expectations.append(
|
|
1091
|
+
f"{train_id}: expectations has invalid key '{key}'"
|
|
1092
|
+
)
|
|
1093
|
+
elif not isinstance(value, bool):
|
|
1094
|
+
invalid_expectations.append(
|
|
1095
|
+
f"{train_id}: expectations.{key} must be boolean, got {type(value).__name__}"
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
assert not invalid_expectations, \
|
|
1099
|
+
f"Invalid expectations structure:\n " + "\n ".join(invalid_expectations)
|
|
1100
|
+
|
|
1101
|
+
|
|
1102
|
+
@pytest.mark.platform
|
|
1103
|
+
def test_train_status_inference(train_files):
|
|
1104
|
+
"""
|
|
1105
|
+
SPEC-TRAIN-VAL-0035: Status inference from expectations
|
|
1106
|
+
|
|
1107
|
+
Given: Train YAML files
|
|
1108
|
+
When: Inferring status from expectations and test/code fields
|
|
1109
|
+
Then: Status can be correctly inferred (planned -> tested -> implemented)
|
|
1110
|
+
|
|
1111
|
+
Section 12: Status Inference
|
|
1112
|
+
"""
|
|
1113
|
+
inferred_results = []
|
|
1114
|
+
|
|
1115
|
+
for train_path, train_data in train_files:
|
|
1116
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
1117
|
+
explicit_status = train_data.get("status")
|
|
1118
|
+
expectations = _infer_expectations(train_data)
|
|
1119
|
+
|
|
1120
|
+
# Verify inference is valid
|
|
1121
|
+
has_backend_test = bool(_normalize_test_code_field(train_data.get("test")).get("backend"))
|
|
1122
|
+
has_backend_code = bool(_normalize_test_code_field(train_data.get("code")).get("backend"))
|
|
1123
|
+
|
|
1124
|
+
inferred_status = "planned"
|
|
1125
|
+
if has_backend_code:
|
|
1126
|
+
inferred_status = "implemented"
|
|
1127
|
+
elif has_backend_test:
|
|
1128
|
+
inferred_status = "tested"
|
|
1129
|
+
|
|
1130
|
+
inferred_results.append({
|
|
1131
|
+
"train_id": train_id,
|
|
1132
|
+
"explicit_status": explicit_status,
|
|
1133
|
+
"inferred_status": inferred_status,
|
|
1134
|
+
"expectations": expectations
|
|
1135
|
+
})
|
|
1136
|
+
|
|
1137
|
+
# This test verifies the inference logic works
|
|
1138
|
+
assert len(inferred_results) >= 0 # Always passes, just validates logic
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
@pytest.mark.platform
|
|
1142
|
+
def test_train_status_expectations_conflict(train_files):
|
|
1143
|
+
"""
|
|
1144
|
+
SPEC-TRAIN-VAL-0036: Status/expectations conflict detection
|
|
1145
|
+
|
|
1146
|
+
Given: Train YAML files with status and expectations
|
|
1147
|
+
When: Checking for conflicts
|
|
1148
|
+
Then: No conflicts between status and expectations
|
|
1149
|
+
|
|
1150
|
+
Section 12: Status/Expectations Conflict Detection
|
|
1151
|
+
"""
|
|
1152
|
+
conflicts = []
|
|
1153
|
+
|
|
1154
|
+
for train_path, train_data in train_files:
|
|
1155
|
+
train_id = train_data.get("train_id", train_path.stem)
|
|
1156
|
+
status = train_data.get("status", "planned")
|
|
1157
|
+
expectations = train_data.get("expectations", {})
|
|
1158
|
+
|
|
1159
|
+
# Detect conflicts
|
|
1160
|
+
if status == "implemented" and expectations.get("backend") is False:
|
|
1161
|
+
conflicts.append(
|
|
1162
|
+
f"{train_id}: status='implemented' but expectations.backend=false"
|
|
1163
|
+
)
|
|
1164
|
+
|
|
1165
|
+
if status == "tested" and expectations.get("backend") is False:
|
|
1166
|
+
conflicts.append(
|
|
1167
|
+
f"{train_id}: status='tested' but expectations.backend=false"
|
|
1168
|
+
)
|
|
1169
|
+
|
|
1170
|
+
if conflicts:
|
|
1171
|
+
if should_enforce(TrainSpecPhase.FULL_ENFORCEMENT):
|
|
1172
|
+
pytest.fail(
|
|
1173
|
+
f"Status/expectations conflicts:\n " + "\n ".join(conflicts)
|
|
1174
|
+
)
|
|
1175
|
+
else:
|
|
1176
|
+
for conflict in conflicts:
|
|
1177
|
+
emit_phase_warning(
|
|
1178
|
+
"SPEC-TRAIN-VAL-0036",
|
|
1179
|
+
conflict,
|
|
1180
|
+
TrainSpecPhase.FULL_ENFORCEMENT
|
|
1181
|
+
)
|