Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,25 @@ All scenarios are defined as YAML files and executed via the runner. See the [sc

## Scenarios

* [Add Flight Declaration](scenarios/add_flight_declaration.md)
* [F1 Happy Path](scenarios/F1_happy_path.md)
* [F2 Contingent Path](scenarios/F2_contingent_path.md)
* [F3 Non Conforming Path](scenarios/F3_non_conforming_path.md)
* [F5 Non Conforming Path](scenarios/F5_non_conforming_path.md)
* [Geo Fence Upload](scenarios/geo_fence_upload.md)
* [Opensky Live Data](scenarios/opensky_live_data.md)
* [OpenUTM Sim Air Traffic Data](scenarios/openutm_sim_air_traffic_data.md)
* [SDSP Heartbeat](scenarios/sdsp_heartbeat.md)
* [SDSP Track](scenarios/sdsp_track.md)
### Flight Declarations
* [Add Flight Declaration](scenarios/flight-declarations/add_flight_declaration.md)
* [Add Flight Declaration (via Operational Intent)](scenarios/flight-declarations/add_flight_declaration_via_operational_intent.md)

### Basic Scenarios
* [F1 Happy Path](scenarios/standard-scenarios/F1_happy_path.md)
* [F2 Contingent Path](scenarios/standard-scenarios/F2_contingent_path.md)
* [F3 Non Conforming Path](scenarios/standard-scenarios/F3_non_conforming_path.md)
* [F5 Non Conforming Path](scenarios/standard-scenarios/F5_non_conforming_path.md)
Comment thread
atti92 marked this conversation as resolved.

### Geo Fence Scenarios
* [Geo Fence Upload](scenarios/geo-fence/geo_fence_upload.md)

## Air Traffic Simulation Scenarios
* [Opensky Live Data](scenarios/airtraffic-simulations/opensky_live_data.md)
* [OpenUTM Sim Air Traffic Data](scenarios/airtraffic-simulations/openutm_sim_air_traffic_data.md)

## SDSP Scenarios
* [SDSP Heartbeat](scenarios/sdsp-f3623/sdsp_heartbeat.md)
* [SDSP Track](scenarios/sdsp-f3623/sdsp_track.md)
* [SDSP Sensor Failure](scenarios/sdsp-f3623/sdsp_verify_sensor_failure_report.md)
* [SDSP Metrics](scenarios/sdsp-f3623/verify_sdsp_metrics.md)
File renamed without changes.
File renamed without changes.
File renamed without changes.
10 changes: 6 additions & 4 deletions scripts/generate_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,21 @@ def main():

DOCS_DIR.mkdir(parents=True, exist_ok=True)

for yaml_file in SCENARIOS_DIR.glob("*.yaml"):
print(f"Processing {yaml_file.name}...")
for yaml_file in sorted(SCENARIOS_DIR.rglob("*.yaml")):
relative = yaml_file.relative_to(SCENARIOS_DIR)
print(f"Processing {relative}...")
try:
with open(yaml_file, "r") as f:
data = yaml.safe_load(f)

if not data:
print(f"Skipping empty file: {yaml_file.name}")
print(f"Skipping empty file: {relative}")
continue

md_content = generate_markdown(data)

md_filename = DOCS_DIR / yaml_file.with_suffix(".md").name
md_filename = (DOCS_DIR / relative).with_suffix(".md")
md_filename.parent.mkdir(parents=True, exist_ok=True)
with open(md_filename, "w") as f:
f.write(md_content)

Expand Down
16 changes: 14 additions & 2 deletions src/openutm_verification/core/execution/scenario_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,21 @@ def load_yaml_scenario_definition(scenario_id: str, base_dir: Path | None = None
"""

scenarios_dir = base_dir or get_scenarios_directory()
scenario_path = scenarios_dir / f"{scenario_id}.yaml"
scenario_path = (scenarios_dir / f"{scenario_id}.yaml").resolve()
if not scenario_path.is_relative_to(scenarios_dir.resolve()):
raise FileNotFoundError(f"Scenario YAML not found: {scenario_id}")
if not scenario_path.exists():
raise FileNotFoundError(f"Scenario YAML not found: {scenario_path}")
if "/" not in scenario_id:
# Bare name: search subfolders for a matching file
matches = list(scenarios_dir.rglob(f"{scenario_id}.yaml"))
if len(matches) == 1:
scenario_path = matches[0]
elif len(matches) > 1:
raise FileNotFoundError(f"Ambiguous scenario '{scenario_id}': found in multiple locations: {[str(m) for m in matches]}")
else:
raise FileNotFoundError(f"Scenario YAML not found: {scenario_id}")
else:
raise FileNotFoundError(f"Scenario YAML not found: {scenario_path}")

with open(scenario_path, "r", encoding="utf-8") as f:
scenario_data = yaml.safe_load(f)
Expand Down
89 changes: 66 additions & 23 deletions src/openutm_verification/server/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,75 @@ async def execute_step(step: StepDefinition, runner: Any = Depends(get_runner)):

@scenario_router.get("/api/scenarios")
async def list_scenarios():
"""List all available scenarios."""
"""List all available scenarios, including those in sub-folders."""
path = get_scenarios_directory()
if not path.exists():
return []
return [f.stem for f in path.glob("*.yaml")]
return [
f.relative_to(path).with_suffix("").as_posix()
for f in sorted(path.rglob("*.yaml"))
]


@scenario_router.get("/api/suites")
async def list_suites(runner: Any = Depends(get_runner)):
"""Return suite-to-scenario mapping from the loaded configuration."""
"""Return suite-to-scenario mapping, resolving bare names to subfolder-relative paths."""
scenarios_path = get_scenarios_directory()
# Build a mapping from stem to all matching scenario IDs to detect ambiguities.
stem_to_ids: dict[str, list[str]] = {}
for f in scenarios_path.rglob("*.yaml"):
stem = f.stem
scenario_id = f.relative_to(scenarios_path).with_suffix("").as_posix()
stem_to_ids.setdefault(stem, []).append(scenario_id)
Comment thread
hrishiballal marked this conversation as resolved.

def resolve_scenario_name(name: str) -> str:
"""Resolve a bare scenario name to its ID if unambiguous.

If there are no scenarios with the given stem, or if multiple scenarios
share the same stem, return the name unchanged so callers can use a
fully-qualified ID instead.
"""
ids = stem_to_ids.get(name)
if not ids:
# No matching stem; leave as-is.
return name
if len(ids) == 1:
# Unique stem; safe to auto-resolve.
return ids[0]
# Ambiguous stem; do not auto-resolve to avoid silently picking one.
return name

config = runner.config
result: dict[str, list[str]] = {}
for suite_name, suite_config in config.suites.items():
if suite_config.scenarios:
result[suite_name] = [s.name for s in suite_config.scenarios]
result[suite_name] = [resolve_scenario_name(s.name) for s in suite_config.scenarios]
else:
Comment thread
hrishiballal marked this conversation as resolved.
result[suite_name] = []
return result


@scenario_router.get("/api/scenarios/{scenario}")
@scenario_router.get("/api/scenarios/{scenario:path}/docs")
async def get_scenario_docs(scenario: str):
"""Get the documentation for a specific scenario."""
docs_dir = get_docs_directory()
file_path = (docs_dir / scenario).with_suffix(".md").resolve()
if not file_path.is_relative_to(docs_dir.resolve()):
raise HTTPException(status_code=404, detail="Documentation not found")
if not file_path.exists():
# Fallback: search by stem for flat doc files not yet reorganised into subfolders
stem = Path(scenario).stem
matches = list(docs_dir.rglob(f"{stem}.md"))
if len(matches) == 1:
file_path = matches[0]
else:
raise HTTPException(status_code=404, detail="Documentation not found")

with open(file_path, "r", encoding="utf-8") as f:
return PlainTextResponse(f.read())


@scenario_router.get("/api/scenarios/{scenario:path}")
async def get_scenario(scenario: str):
"""Get the content of a specific scenario."""
try:
Expand All @@ -67,14 +115,22 @@ async def get_scenario(scenario: str):
raise HTTPException(status_code=500, detail=f"Invalid YAML: {e}")


@scenario_router.post("/api/scenarios/{name}")
@scenario_router.post("/api/scenarios/{name:path}")
async def save_scenario(name: str, scenario: ScenarioDefinition):
"""Save a scenario to a YAML file."""
path = get_scenarios_directory()
file_path = (path / name).with_suffix(".yaml")
base_dir = get_scenarios_directory().resolve()

# Ensure directory exists
path.mkdir(parents=True, exist_ok=True)
# Reject absolute paths in the name to prevent writing outside the scenarios directory
if Path(name).is_absolute():
raise HTTPException(status_code=400, detail="Invalid scenario name")

# Normalize and validate the target path to prevent directory traversal
file_path = (base_dir / name).with_suffix(".yaml").resolve()
if not file_path.is_relative_to(base_dir):
raise HTTPException(status_code=400, detail="Invalid scenario name")

# Ensure directory exists (including any sub-folder)
file_path.parent.mkdir(parents=True, exist_ok=True)

Comment on lines +118 to 134
try:
# Convert Pydantic model to dict, excluding None values to keep YAML clean
Expand All @@ -88,19 +144,6 @@ async def save_scenario(name: str, scenario: ScenarioDefinition):
raise HTTPException(status_code=500, detail=f"Failed to save scenario: {e}")


@scenario_router.get("/api/scenarios/{scenario}/docs")
async def get_scenario_docs(scenario: str):
"""Get the documentation for a specific scenario."""
file_path = (get_docs_directory() / scenario).with_suffix(".md")

if not file_path.exists():
raise HTTPException(status_code=404, detail="Documentation not found")

with open(file_path, "r") as f:
content = f.read()
return PlainTextResponse(content)


@scenario_router.get("/api/reports/latest")
async def get_latest_report(request: Request, scenario: str | None = None):
"""Redirect to the latest generated report. Optionally filter by scenario name."""
Expand Down
4 changes: 2 additions & 2 deletions tests/test_yaml_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from openutm_verification.server.runner import SessionManager

SCENARIOS_DIR = Path(os.getenv("SCENARIOS_PATH", Path(__file__).parent.parent / "scenarios"))
YAML_FILES = list(SCENARIOS_DIR.glob("*.yaml"))
YAML_FILES = sorted(SCENARIOS_DIR.rglob("*.yaml"))


@pytest.fixture
Expand Down Expand Up @@ -68,7 +68,7 @@ def mock_data_files():


@pytest.mark.asyncio
@pytest.mark.parametrize("yaml_file", YAML_FILES, ids=[f.name for f in YAML_FILES])
@pytest.mark.parametrize("yaml_file", YAML_FILES, ids=[str(f.relative_to(SCENARIOS_DIR)) for f in YAML_FILES])
async def test_yaml_scenario_execution(yaml_file, mock_clients, mock_data_files):
"""Verify that each YAML scenario can be loaded and executed with mocked clients."""

Expand Down
90 changes: 71 additions & 19 deletions web-editor/src/components/ScenarioEditor/ScenarioList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,21 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName,

const hasSuites = Object.keys(suites).length > 0;

const folderGroups = useMemo(() => {
const map: Record<string, string[]> = {};
for (const scenario of scenarios) {
const parts = scenario.split('/');
const folder = parts.length > 1 ? parts.slice(0, -1).join('/') : '';
if (!map[folder]) map[folder] = [];
map[folder].push(scenario);
}
return Object.entries(map).sort(([a], [b]) => {
if (a === '') return -1;
if (b === '') return 1;
return a.localeCompare(b);
});
}, [scenarios]);

const groupedScenarios = useMemo(() => {
const suiteNames = Object.keys(suites).sort((a, b) => a.localeCompare(b));
const scenarioSet = new Set(scenarios);
Expand Down Expand Up @@ -109,25 +124,28 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName,
}
};

const renderScenarioItem = (name: string) => (
<div
key={name}
className={styles.nodeItem}
onClick={() => handleLoad(name)}
role="button"
tabIndex={0}
title={name}
style={{
cursor: 'pointer',
opacity: loading ? 0.5 : 1,
borderColor: name === currentScenarioName ? 'var(--accent-primary)' : 'var(--border-color)',
backgroundColor: name === currentScenarioName ? 'var(--bg-secondary)' : 'var(--bg-primary)'
}}
>
<FileText size={16} color={name === currentScenarioName ? "var(--accent-primary)" : "#8b949e"} />
<span>{name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
</div>
);
const renderScenarioItem = (name: string) => {
const displayName = name.split('/').pop() ?? name;
return (
<button
key={name}
type="button"
className={styles.nodeItem}
onClick={() => handleLoad(name)}
title={name}
Comment thread
hrishiballal marked this conversation as resolved.
disabled={loading}
style={{
cursor: loading ? 'not-allowed' : 'pointer',
opacity: loading ? 0.5 : 1,
borderColor: name === currentScenarioName ? 'var(--accent-primary)' : 'var(--border-color)',
backgroundColor: name === currentScenarioName ? 'var(--bg-secondary)' : 'var(--bg-primary)'
}}
>
<FileText size={16} color={name === currentScenarioName ? "var(--accent-primary)" : "#8b949e"} />
<span>{displayName.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())}</span>
</button>
);
};

return (
<div>
Expand Down Expand Up @@ -164,6 +182,40 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName,
</div>
);
})
) : folderGroups.some(([folder]) => folder !== '') ? (
folderGroups.map(([folder, items]) => {
const isCollapsed = collapsedSuites.has(`__folder__${folder}`);
const label = folder === ''
? 'Root'
: folder.replace(/\//g, ' / ').replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
return folder === '' ? (
<div key="root" style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{items.map(renderScenarioItem)}
</div>
) : (
<div key={folder} style={{ marginBottom: '4px' }}>
<button
type="button"
className={styles.groupHeader}
onClick={() => toggleSuite(`__folder__${folder}`)}
aria-expanded={!isCollapsed}
style={{ padding: '6px 4px', marginTop: 4, marginBottom: 4, background: 'none', border: 'none', width: '100%' }}
>
{isCollapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
<FolderOpen size={14} />
{label}
<span style={{ marginLeft: 'auto', fontSize: '11px', fontWeight: 400, opacity: 0.7 }}>
{items.length}
</span>
</button>
{!isCollapsed && (
<div style={{ paddingLeft: '8px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
{items.map(renderScenarioItem)}
</div>
)}
</div>
);
})
Comment on lines +185 to +218
) : (
scenarios.map(renderScenarioItem)
)}
Expand Down
Loading