soleprint init commit
This commit is contained in:
1
station/tools/tester/gherkin/__init__.py
Normal file
1
station/tools/tester/gherkin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Gherkin integration for tester."""
|
||||
175
station/tools/tester/gherkin/mapper.py
Normal file
175
station/tools/tester/gherkin/mapper.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Map tests to Gherkin scenarios based on metadata.
|
||||
|
||||
Tests can declare their Gherkin metadata via docstrings:
|
||||
|
||||
```python
|
||||
def test_coverage_check(self):
|
||||
'''
|
||||
Feature: Reservar turno veterinario
|
||||
Scenario: Verificar cobertura en zona disponible
|
||||
Tags: @smoke @coverage
|
||||
'''
|
||||
```
|
||||
|
||||
Or via class docstrings:
|
||||
|
||||
```python
|
||||
class TestCoverageFlow(ContractHTTPTestCase):
|
||||
"""
|
||||
Feature: Reservar turno veterinario
|
||||
Tags: @coverage
|
||||
"""
|
||||
```
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestGherkinMetadata:
|
||||
"""Gherkin metadata extracted from a test."""
|
||||
feature: Optional[str] = None
|
||||
scenario: Optional[str] = None
|
||||
tags: list[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
|
||||
def extract_gherkin_metadata(docstring: Optional[str]) -> TestGherkinMetadata:
|
||||
"""
|
||||
Extract Gherkin metadata from a test docstring.
|
||||
|
||||
Looks for:
|
||||
- Feature: <name>
|
||||
- Scenario: <name>
|
||||
- Tags: @tag1 @tag2
|
||||
|
||||
Args:
|
||||
docstring: Test or class docstring
|
||||
|
||||
Returns:
|
||||
TestGherkinMetadata with extracted info
|
||||
"""
|
||||
if not docstring:
|
||||
return TestGherkinMetadata()
|
||||
|
||||
# Extract Feature
|
||||
feature = None
|
||||
feature_match = re.search(r"Feature:\s*(.+)", docstring)
|
||||
if feature_match:
|
||||
feature = feature_match.group(1).strip()
|
||||
|
||||
# Extract Scenario (also try Spanish: Escenario)
|
||||
scenario = None
|
||||
scenario_match = re.search(r"(Scenario|Escenario):\s*(.+)", docstring)
|
||||
if scenario_match:
|
||||
scenario = scenario_match.group(2).strip()
|
||||
|
||||
# Extract Tags
|
||||
tags = []
|
||||
tags_match = re.search(r"Tags:\s*(.+)", docstring)
|
||||
if tags_match:
|
||||
tags_str = tags_match.group(1).strip()
|
||||
tags = re.findall(r"@[\w-]+", tags_str)
|
||||
|
||||
return TestGherkinMetadata(
|
||||
feature=feature,
|
||||
scenario=scenario,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
|
||||
def has_gherkin_metadata(docstring: Optional[str]) -> bool:
|
||||
"""Check if a docstring contains Gherkin metadata."""
|
||||
if not docstring:
|
||||
return False
|
||||
|
||||
return bool(
|
||||
re.search(r"Feature:\s*", docstring) or
|
||||
re.search(r"Scenario:\s*", docstring) or
|
||||
re.search(r"Escenario:\s*", docstring) or
|
||||
re.search(r"Tags:\s*@", docstring)
|
||||
)
|
||||
|
||||
|
||||
def match_test_to_feature(
|
||||
test_metadata: TestGherkinMetadata,
|
||||
feature_names: list[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Match a test's feature metadata to an actual feature name.
|
||||
|
||||
Uses fuzzy matching if exact match not found.
|
||||
|
||||
Args:
|
||||
test_metadata: Extracted test metadata
|
||||
feature_names: List of available feature names
|
||||
|
||||
Returns:
|
||||
Matched feature name or None
|
||||
"""
|
||||
if not test_metadata.feature:
|
||||
return None
|
||||
|
||||
# Exact match
|
||||
if test_metadata.feature in feature_names:
|
||||
return test_metadata.feature
|
||||
|
||||
# Case-insensitive match
|
||||
test_feature_lower = test_metadata.feature.lower()
|
||||
for feature_name in feature_names:
|
||||
if feature_name.lower() == test_feature_lower:
|
||||
return feature_name
|
||||
|
||||
# Partial match (feature name contains test feature or vice versa)
|
||||
for feature_name in feature_names:
|
||||
if test_feature_lower in feature_name.lower():
|
||||
return feature_name
|
||||
if feature_name.lower() in test_feature_lower:
|
||||
return feature_name
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def match_test_to_scenario(
|
||||
test_metadata: TestGherkinMetadata,
|
||||
scenario_names: list[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Match a test's scenario metadata to an actual scenario name.
|
||||
|
||||
Uses fuzzy matching if exact match not found.
|
||||
|
||||
Args:
|
||||
test_metadata: Extracted test metadata
|
||||
scenario_names: List of available scenario names
|
||||
|
||||
Returns:
|
||||
Matched scenario name or None
|
||||
"""
|
||||
if not test_metadata.scenario:
|
||||
return None
|
||||
|
||||
# Exact match
|
||||
if test_metadata.scenario in scenario_names:
|
||||
return test_metadata.scenario
|
||||
|
||||
# Case-insensitive match
|
||||
test_scenario_lower = test_metadata.scenario.lower()
|
||||
for scenario_name in scenario_names:
|
||||
if scenario_name.lower() == test_scenario_lower:
|
||||
return scenario_name
|
||||
|
||||
# Partial match
|
||||
for scenario_name in scenario_names:
|
||||
if test_scenario_lower in scenario_name.lower():
|
||||
return scenario_name
|
||||
if scenario_name.lower() in test_scenario_lower:
|
||||
return scenario_name
|
||||
|
||||
return None
|
||||
231
station/tools/tester/gherkin/parser.py
Normal file
231
station/tools/tester/gherkin/parser.py
Normal file
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Parse Gherkin .feature files.
|
||||
|
||||
Simple parser without external dependencies - parses the subset we need.
|
||||
For full Gherkin support, could use gherkin-python package later.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass
|
||||
class GherkinScenario:
|
||||
"""A Gherkin scenario."""
|
||||
name: str
|
||||
description: str
|
||||
tags: list[str] = field(default_factory=list)
|
||||
steps: list[str] = field(default_factory=list)
|
||||
examples: dict = field(default_factory=dict)
|
||||
scenario_type: str = "Scenario" # or "Scenario Outline" / "Esquema del escenario"
|
||||
|
||||
|
||||
@dataclass
|
||||
class GherkinFeature:
|
||||
"""A parsed Gherkin feature file."""
|
||||
name: str
|
||||
description: str
|
||||
file_path: str
|
||||
language: str = "en" # or "es"
|
||||
tags: list[str] = field(default_factory=list)
|
||||
background: Optional[dict] = None
|
||||
scenarios: list[GherkinScenario] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_feature_file(file_path: Path) -> Optional[GherkinFeature]:
|
||||
"""
|
||||
Parse a Gherkin .feature file.
|
||||
|
||||
Supports both English and Spanish keywords.
|
||||
Extracts: Feature name, scenarios, tags, steps.
|
||||
"""
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding='utf-8')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Detect language
|
||||
language = "en"
|
||||
if re.search(r"#\s*language:\s*es", content):
|
||||
language = "es"
|
||||
|
||||
# Keywords by language
|
||||
if language == "es":
|
||||
feature_kw = r"Característica"
|
||||
scenario_kw = r"Escenario"
|
||||
outline_kw = r"Esquema del escenario"
|
||||
background_kw = r"Antecedentes"
|
||||
examples_kw = r"Ejemplos"
|
||||
given_kw = r"Dado"
|
||||
when_kw = r"Cuando"
|
||||
then_kw = r"Entonces"
|
||||
and_kw = r"Y"
|
||||
but_kw = r"Pero"
|
||||
else:
|
||||
feature_kw = r"Feature"
|
||||
scenario_kw = r"Scenario"
|
||||
outline_kw = r"Scenario Outline"
|
||||
background_kw = r"Background"
|
||||
examples_kw = r"Examples"
|
||||
given_kw = r"Given"
|
||||
when_kw = r"When"
|
||||
then_kw = r"Then"
|
||||
and_kw = r"And"
|
||||
but_kw = r"But"
|
||||
|
||||
lines = content.split('\n')
|
||||
|
||||
# Extract feature
|
||||
feature_name = None
|
||||
feature_desc = []
|
||||
feature_tags = []
|
||||
scenarios = []
|
||||
current_scenario = None
|
||||
current_tags = []
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
|
||||
# Skip comments and empty lines
|
||||
if not line or line.startswith('#'):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Tags
|
||||
if line.startswith('@'):
|
||||
tags = re.findall(r'@[\w-]+', line)
|
||||
current_tags.extend(tags)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Feature
|
||||
feature_match = re.match(rf"^{feature_kw}:\s*(.+)", line)
|
||||
if feature_match:
|
||||
feature_name = feature_match.group(1).strip()
|
||||
feature_tags = current_tags.copy()
|
||||
current_tags = []
|
||||
|
||||
# Read feature description
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
if not line or line.startswith('#'):
|
||||
i += 1
|
||||
continue
|
||||
# Stop at scenario or background
|
||||
if re.match(rf"^({scenario_kw}|{outline_kw}|{background_kw}):", line):
|
||||
break
|
||||
feature_desc.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Scenario
|
||||
scenario_match = re.match(rf"^({scenario_kw}|{outline_kw}):\s*(.+)", line)
|
||||
if scenario_match:
|
||||
# Save previous scenario
|
||||
if current_scenario:
|
||||
scenarios.append(current_scenario)
|
||||
|
||||
scenario_type = scenario_match.group(1)
|
||||
scenario_name = scenario_match.group(2).strip()
|
||||
|
||||
current_scenario = GherkinScenario(
|
||||
name=scenario_name,
|
||||
description="",
|
||||
tags=current_tags.copy(),
|
||||
steps=[],
|
||||
scenario_type=scenario_type
|
||||
)
|
||||
current_tags = []
|
||||
|
||||
# Read scenario steps
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
|
||||
# Empty or comment
|
||||
if not line or line.startswith('#'):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# New scenario or feature-level element
|
||||
if re.match(rf"^({scenario_kw}|{outline_kw}|{examples_kw}):", line):
|
||||
break
|
||||
|
||||
# Tags (start of next scenario)
|
||||
if line.startswith('@'):
|
||||
break
|
||||
|
||||
# Step keywords
|
||||
if re.match(rf"^({given_kw}|{when_kw}|{then_kw}|{and_kw}|{but_kw})\s+", line):
|
||||
current_scenario.steps.append(line)
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
i += 1
|
||||
|
||||
# Add last scenario
|
||||
if current_scenario:
|
||||
scenarios.append(current_scenario)
|
||||
|
||||
if not feature_name:
|
||||
return None
|
||||
|
||||
return GherkinFeature(
|
||||
name=feature_name,
|
||||
description=" ".join(feature_desc),
|
||||
file_path=str(file_path),
|
||||
language=language,
|
||||
tags=feature_tags,
|
||||
scenarios=scenarios
|
||||
)
|
||||
|
||||
|
||||
def discover_features(features_dir: Path) -> list[GherkinFeature]:
|
||||
"""
|
||||
Discover all .feature files in the features directory.
|
||||
"""
|
||||
if not features_dir.exists():
|
||||
return []
|
||||
|
||||
features = []
|
||||
|
||||
for feature_file in features_dir.rglob("*.feature"):
|
||||
parsed = parse_feature_file(feature_file)
|
||||
if parsed:
|
||||
features.append(parsed)
|
||||
|
||||
return features
|
||||
|
||||
|
||||
def extract_tags_from_features(features: list[GherkinFeature]) -> set[str]:
|
||||
"""Extract all unique tags from features."""
|
||||
tags = set()
|
||||
|
||||
for feature in features:
|
||||
tags.update(feature.tags)
|
||||
for scenario in feature.scenarios:
|
||||
tags.update(scenario.tags)
|
||||
|
||||
return tags
|
||||
|
||||
|
||||
def get_feature_names(features: list[GherkinFeature]) -> list[str]:
|
||||
"""Get list of feature names."""
|
||||
return [f.name for f in features]
|
||||
|
||||
|
||||
def get_scenario_names(features: list[GherkinFeature]) -> list[str]:
|
||||
"""Get list of all scenario names across all features."""
|
||||
scenarios = []
|
||||
for feature in features:
|
||||
for scenario in feature.scenarios:
|
||||
scenarios.append(scenario.name)
|
||||
return scenarios
|
||||
93
station/tools/tester/gherkin/sync.py
Normal file
93
station/tools/tester/gherkin/sync.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""
|
||||
Sync Gherkin feature files from album/book/gherkin-samples/ to tester/features/.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def sync_features_from_album(
|
||||
album_path: Optional[Path] = None,
|
||||
tester_path: Optional[Path] = None
|
||||
) -> dict:
|
||||
"""
|
||||
Sync .feature files from album/book/gherkin-samples/ to ward/tools/tester/features/.
|
||||
|
||||
Args:
|
||||
album_path: Path to album/book/gherkin-samples/ (auto-detected if None)
|
||||
tester_path: Path to ward/tools/tester/features/ (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
Dict with sync stats: {synced: int, skipped: int, errors: int}
|
||||
"""
|
||||
# Auto-detect paths if not provided
|
||||
if tester_path is None:
|
||||
tester_path = Path(__file__).parent.parent / "features"
|
||||
|
||||
if album_path is None:
|
||||
# Attempt to find album in pawprint
|
||||
pawprint_root = Path(__file__).parent.parent.parent.parent
|
||||
album_path = pawprint_root / "album" / "book" / "gherkin-samples"
|
||||
|
||||
# Ensure paths exist
|
||||
if not album_path.exists():
|
||||
return {
|
||||
"synced": 0,
|
||||
"skipped": 0,
|
||||
"errors": 1,
|
||||
"message": f"Album path not found: {album_path}"
|
||||
}
|
||||
|
||||
tester_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Sync stats
|
||||
synced = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
# Find all .feature files in album
|
||||
for feature_file in album_path.rglob("*.feature"):
|
||||
# Get relative path from album root
|
||||
relative_path = feature_file.relative_to(album_path)
|
||||
|
||||
# Destination path
|
||||
dest_file = tester_path / relative_path
|
||||
|
||||
try:
|
||||
# Create parent directories
|
||||
dest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Copy file
|
||||
shutil.copy2(feature_file, dest_file)
|
||||
synced += 1
|
||||
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
|
||||
return {
|
||||
"synced": synced,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"message": f"Synced {synced} feature files from {album_path}"
|
||||
}
|
||||
|
||||
|
||||
def clean_features_dir(features_dir: Optional[Path] = None):
|
||||
"""
|
||||
Clean the features directory (remove all .feature files).
|
||||
|
||||
Useful before re-syncing to ensure no stale files.
|
||||
"""
|
||||
if features_dir is None:
|
||||
features_dir = Path(__file__).parent.parent / "features"
|
||||
|
||||
if not features_dir.exists():
|
||||
return
|
||||
|
||||
# Remove all .feature files
|
||||
for feature_file in features_dir.rglob("*.feature"):
|
||||
try:
|
||||
feature_file.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
Reference in New Issue
Block a user