diff --git a/docs/index.md b/docs/index.md index 8b25f9c6..75054099 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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) + +### 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) diff --git a/docs/scenarios/bluesky_sim_air_traffic_data.md b/docs/scenarios/airtraffic-simulations/bluesky_sim_air_traffic_data.md similarity index 100% rename from docs/scenarios/bluesky_sim_air_traffic_data.md rename to docs/scenarios/airtraffic-simulations/bluesky_sim_air_traffic_data.md diff --git a/docs/scenarios/opensky_live_data.md b/docs/scenarios/airtraffic-simulations/opensky_live_data.md similarity index 100% rename from docs/scenarios/opensky_live_data.md rename to docs/scenarios/airtraffic-simulations/opensky_live_data.md diff --git a/docs/scenarios/openutm_sim_air_traffic_data.md b/docs/scenarios/airtraffic-simulations/openutm_sim_air_traffic_data.md similarity index 100% rename from docs/scenarios/openutm_sim_air_traffic_data.md rename to docs/scenarios/airtraffic-simulations/openutm_sim_air_traffic_data.md diff --git a/docs/scenarios/add_flight_declaration.md b/docs/scenarios/flight-declarations/add_flight_declaration.md similarity index 100% rename from docs/scenarios/add_flight_declaration.md rename to docs/scenarios/flight-declarations/add_flight_declaration.md diff --git a/docs/scenarios/add_flight_declaration_via_operational_intent.md b/docs/scenarios/flight-declarations/add_flight_declaration_via_operational_intent.md similarity index 100% rename from docs/scenarios/add_flight_declaration_via_operational_intent.md rename to docs/scenarios/flight-declarations/add_flight_declaration_via_operational_intent.md diff --git a/docs/scenarios/geo_fence_upload.md b/docs/scenarios/geo-fence/geo_fence_upload.md similarity index 100% rename from docs/scenarios/geo_fence_upload.md rename to docs/scenarios/geo-fence/geo_fence_upload.md diff --git a/docs/scenarios/sdsp_heartbeat.md b/docs/scenarios/sdsp-f3623/sdsp_heartbeat.md similarity index 100% rename from docs/scenarios/sdsp_heartbeat.md rename to docs/scenarios/sdsp-f3623/sdsp_heartbeat.md diff --git a/docs/scenarios/sdsp_track.md b/docs/scenarios/sdsp-f3623/sdsp_track.md similarity index 100% rename from docs/scenarios/sdsp_track.md rename to docs/scenarios/sdsp-f3623/sdsp_track.md diff --git a/docs/scenarios/sdsp_verify_sensor_failure_report.md b/docs/scenarios/sdsp-f3623/sdsp_verify_sensor_failure_report.md similarity index 100% rename from docs/scenarios/sdsp_verify_sensor_failure_report.md rename to docs/scenarios/sdsp-f3623/sdsp_verify_sensor_failure_report.md diff --git a/docs/scenarios/verify_sdsp_metrics.md b/docs/scenarios/sdsp-f3623/verify_sdsp_metrics.md similarity index 100% rename from docs/scenarios/verify_sdsp_metrics.md rename to docs/scenarios/sdsp-f3623/verify_sdsp_metrics.md diff --git a/docs/scenarios/F1_flow_no_telemetry_with_user_input.md b/docs/scenarios/standard-scenarios/F1_flow_no_telemetry_with_user_input.md similarity index 100% rename from docs/scenarios/F1_flow_no_telemetry_with_user_input.md rename to docs/scenarios/standard-scenarios/F1_flow_no_telemetry_with_user_input.md diff --git a/docs/scenarios/F1_happy_path.md b/docs/scenarios/standard-scenarios/F1_happy_path.md similarity index 100% rename from docs/scenarios/F1_happy_path.md rename to docs/scenarios/standard-scenarios/F1_happy_path.md diff --git a/docs/scenarios/F2_contingent_path.md b/docs/scenarios/standard-scenarios/F2_contingent_path.md similarity index 100% rename from docs/scenarios/F2_contingent_path.md rename to docs/scenarios/standard-scenarios/F2_contingent_path.md diff --git a/docs/scenarios/F3_non_conforming_path.md b/docs/scenarios/standard-scenarios/F3_non_conforming_path.md similarity index 100% rename from docs/scenarios/F3_non_conforming_path.md rename to docs/scenarios/standard-scenarios/F3_non_conforming_path.md diff --git a/docs/scenarios/F5_non_conforming_path.md b/docs/scenarios/standard-scenarios/F5_non_conforming_path.md similarity index 100% rename from docs/scenarios/F5_non_conforming_path.md rename to docs/scenarios/standard-scenarios/F5_non_conforming_path.md diff --git a/scenarios/bayesian_sim_air_traffic_data.yaml b/scenarios/airtraffic-simulations/bayesian_sim_air_traffic_data.yaml similarity index 100% rename from scenarios/bayesian_sim_air_traffic_data.yaml rename to scenarios/airtraffic-simulations/bayesian_sim_air_traffic_data.yaml diff --git a/scenarios/bayesian_sim_air_traffic_data_varying_refresh_rates.yaml b/scenarios/airtraffic-simulations/bayesian_sim_air_traffic_data_varying_refresh_rates.yaml similarity index 100% rename from scenarios/bayesian_sim_air_traffic_data_varying_refresh_rates.yaml rename to scenarios/airtraffic-simulations/bayesian_sim_air_traffic_data_varying_refresh_rates.yaml diff --git a/scenarios/bluesky_sim_air_traffic_data.yaml b/scenarios/airtraffic-simulations/bluesky_sim_air_traffic_data.yaml similarity index 100% rename from scenarios/bluesky_sim_air_traffic_data.yaml rename to scenarios/airtraffic-simulations/bluesky_sim_air_traffic_data.yaml diff --git a/scenarios/bluesky_sim_air_traffic_data_latency_issues.yaml b/scenarios/airtraffic-simulations/bluesky_sim_air_traffic_data_latency_issues.yaml similarity index 100% rename from scenarios/bluesky_sim_air_traffic_data_latency_issues.yaml rename to scenarios/airtraffic-simulations/bluesky_sim_air_traffic_data_latency_issues.yaml diff --git a/scenarios/opensky_live_data.yaml b/scenarios/airtraffic-simulations/opensky_live_data.yaml similarity index 100% rename from scenarios/opensky_live_data.yaml rename to scenarios/airtraffic-simulations/opensky_live_data.yaml diff --git a/scenarios/openutm_sim_air_traffic_data.yaml b/scenarios/airtraffic-simulations/openutm_sim_air_traffic_data.yaml similarity index 100% rename from scenarios/openutm_sim_air_traffic_data.yaml rename to scenarios/airtraffic-simulations/openutm_sim_air_traffic_data.yaml diff --git a/scenarios/stream_air_traffic_example.yaml b/scenarios/airtraffic-simulations/stream_air_traffic_example.yaml similarity index 100% rename from scenarios/stream_air_traffic_example.yaml rename to scenarios/airtraffic-simulations/stream_air_traffic_example.yaml diff --git a/scenarios/add_flight_declaration.yaml b/scenarios/flight-declarations/add_flight_declaration.yaml similarity index 100% rename from scenarios/add_flight_declaration.yaml rename to scenarios/flight-declarations/add_flight_declaration.yaml diff --git a/scenarios/add_flight_declaration_via_operational_intent.yaml b/scenarios/flight-declarations/add_flight_declaration_via_operational_intent.yaml similarity index 100% rename from scenarios/add_flight_declaration_via_operational_intent.yaml rename to scenarios/flight-declarations/add_flight_declaration_via_operational_intent.yaml diff --git a/scenarios/bulk_add_flight_declarations.yaml b/scenarios/flight-declarations/bulk_add_flight_declarations.yaml similarity index 100% rename from scenarios/bulk_add_flight_declarations.yaml rename to scenarios/flight-declarations/bulk_add_flight_declarations.yaml diff --git a/scenarios/bulk_add_flight_declarations_via_operational_intents.yaml b/scenarios/flight-declarations/bulk_add_flight_declarations_via_operational_intents.yaml similarity index 100% rename from scenarios/bulk_add_flight_declarations_via_operational_intents.yaml rename to scenarios/flight-declarations/bulk_add_flight_declarations_via_operational_intents.yaml diff --git a/scenarios/geo_fence_upload.yaml b/scenarios/geo-fence/geo_fence_upload.yaml similarity index 100% rename from scenarios/geo_fence_upload.yaml rename to scenarios/geo-fence/geo_fence_upload.yaml diff --git a/scenarios/sdsp_heartbeat.yaml b/scenarios/sdsp-f3623/sdsp_heartbeat.yaml similarity index 100% rename from scenarios/sdsp_heartbeat.yaml rename to scenarios/sdsp-f3623/sdsp_heartbeat.yaml diff --git a/scenarios/sdsp_track.yaml b/scenarios/sdsp-f3623/sdsp_track.yaml similarity index 100% rename from scenarios/sdsp_track.yaml rename to scenarios/sdsp-f3623/sdsp_track.yaml diff --git a/scenarios/sdsp_verify_sensor_failure_report.yaml b/scenarios/sdsp-f3623/sdsp_verify_sensor_failure_report.yaml similarity index 100% rename from scenarios/sdsp_verify_sensor_failure_report.yaml rename to scenarios/sdsp-f3623/sdsp_verify_sensor_failure_report.yaml diff --git a/scenarios/verify_sdsp_metrics.yaml b/scenarios/sdsp-f3623/verify_sdsp_metrics.yaml similarity index 100% rename from scenarios/verify_sdsp_metrics.yaml rename to scenarios/sdsp-f3623/verify_sdsp_metrics.yaml diff --git a/scenarios/F1_flow_no_telemetry_with_user_input.yaml b/scenarios/standard-scenarios/F1_flow_no_telemetry_with_user_input.yaml similarity index 100% rename from scenarios/F1_flow_no_telemetry_with_user_input.yaml rename to scenarios/standard-scenarios/F1_flow_no_telemetry_with_user_input.yaml diff --git a/scenarios/F1_happy_path.yaml b/scenarios/standard-scenarios/F1_happy_path.yaml similarity index 100% rename from scenarios/F1_happy_path.yaml rename to scenarios/standard-scenarios/F1_happy_path.yaml diff --git a/scenarios/F2_contingent_path.yaml b/scenarios/standard-scenarios/F2_contingent_path.yaml similarity index 100% rename from scenarios/F2_contingent_path.yaml rename to scenarios/standard-scenarios/F2_contingent_path.yaml diff --git a/scenarios/F3_non_conforming_path.yaml b/scenarios/standard-scenarios/F3_non_conforming_path.yaml similarity index 100% rename from scenarios/F3_non_conforming_path.yaml rename to scenarios/standard-scenarios/F3_non_conforming_path.yaml diff --git a/scenarios/F5_non_conforming_path.yaml b/scenarios/standard-scenarios/F5_non_conforming_path.yaml similarity index 100% rename from scenarios/F5_non_conforming_path.yaml rename to scenarios/standard-scenarios/F5_non_conforming_path.yaml diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index 34dc018e..c4f68286 100644 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -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) diff --git a/src/openutm_verification/core/execution/scenario_loader.py b/src/openutm_verification/core/execution/scenario_loader.py index e279dca9..a1396f3e 100644 --- a/src/openutm_verification/core/execution/scenario_loader.py +++ b/src/openutm_verification/core/execution/scenario_loader.py @@ -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) diff --git a/src/openutm_verification/server/router.py b/src/openutm_verification/server/router.py index 22f40d58..2819bd2e 100644 --- a/src/openutm_verification/server/router.py +++ b/src/openutm_verification/server/router.py @@ -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) + + 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: 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: @@ -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) try: # Convert Pydantic model to dict, excluding None values to keep YAML clean @@ -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.""" diff --git a/tests/test_yaml_scenarios.py b/tests/test_yaml_scenarios.py index 7b89309c..6ebbceb4 100644 --- a/tests/test_yaml_scenarios.py +++ b/tests/test_yaml_scenarios.py @@ -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 @@ -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.""" diff --git a/web-editor/src/components/ScenarioEditor/ScenarioList.tsx b/web-editor/src/components/ScenarioEditor/ScenarioList.tsx index 3a325c76..478f953d 100644 --- a/web-editor/src/components/ScenarioEditor/ScenarioList.tsx +++ b/web-editor/src/components/ScenarioEditor/ScenarioList.tsx @@ -54,6 +54,21 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName, const hasSuites = Object.keys(suites).length > 0; + const folderGroups = useMemo(() => { + const map: Record = {}; + 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); @@ -109,25 +124,28 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName, } }; - const renderScenarioItem = (name: string) => ( -
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)' - }} - > - - {name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} -
- ); + const renderScenarioItem = (name: string) => { + const displayName = name.split('/').pop() ?? name; + return ( + + ); + }; return (
@@ -164,6 +182,40 @@ export const ScenarioList = ({ onLoadScenario, operations, currentScenarioName,
); }) + ) : 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 === '' ? ( +
+ {items.map(renderScenarioItem)} +
+ ) : ( +
+ + {!isCollapsed && ( +
+ {items.map(renderScenarioItem)} +
+ )} +
+ ); + }) ) : ( scenarios.map(renderScenarioItem) )}