use crate::{
types::{
entities::{Entities, ScenarioObject},
scenario::triggers::Condition,
scenario::{
story::{Act, Event, Maneuver, ManeuverGroup},
storyboard::Storyboard,
ScenarioStory,
},
EntityRef, ObjectType, ValidationContext,
},
FileHeader, OpenScenario,
};
use std::collections::{HashMap, HashSet};
#[derive(Debug)]
pub struct ScenarioValidator {
config: ValidationConfig,
validation_cache: HashMap<String, ValidationResult>,
}
#[derive(Debug, Clone)]
pub struct ValidationConfig {
pub strict_mode: bool,
pub validate_references: bool,
pub validate_constraints: bool,
pub validate_semantics: bool,
pub max_errors: usize,
pub use_cache: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
strict_mode: false,
validate_references: true,
validate_constraints: true,
validate_semantics: true,
max_errors: 100,
use_cache: true,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
pub metrics: ValidationMetrics,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationError {
pub category: ValidationErrorCategory,
pub location: String,
pub message: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationWarning {
pub category: ValidationWarningCategory,
pub location: String,
pub message: String,
pub suggestion: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ValidationErrorCategory {
MissingRequired,
InvalidReference,
ConstraintViolation,
SemanticError,
TypeMismatch,
ParameterError,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ValidationWarningCategory {
Deprecated,
Suspicious,
Performance,
BestPractice,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ValidationMetrics {
pub duration_ms: u64,
pub elements_validated: usize,
pub cache_hit_ratio: f64,
}
impl Default for ScenarioValidator {
fn default() -> Self {
Self::new()
}
}
impl ScenarioValidator {
pub fn new() -> Self {
Self {
config: ValidationConfig::default(),
validation_cache: HashMap::new(),
}
}
pub fn with_config(config: ValidationConfig) -> Self {
Self {
config,
validation_cache: HashMap::new(),
}
}
pub fn validate_scenario(&mut self, scenario: &OpenScenario) -> ValidationResult {
let start_time = std::time::Instant::now();
let context = self.build_validation_context(scenario);
let mut result = ValidationResult {
errors: Vec::new(),
warnings: Vec::new(),
metrics: ValidationMetrics {
duration_ms: 0,
elements_validated: 0,
cache_hit_ratio: 0.0,
},
};
self.validate_file_header(&scenario.file_header, &mut result);
match scenario.document_type() {
crate::types::scenario::storyboard::OpenScenarioDocumentType::Scenario => {
if let Some(entities) = &scenario.entities {
self.validate_entities(entities, &context, &mut result);
}
if let Some(storyboard) = &scenario.storyboard {
self.validate_storyboard(storyboard, &context, &mut result);
}
}
crate::types::scenario::storyboard::OpenScenarioDocumentType::ParameterVariation => {
}
crate::types::scenario::storyboard::OpenScenarioDocumentType::Catalog => {
}
crate::types::scenario::storyboard::OpenScenarioDocumentType::Unknown => {
result.errors.push(ValidationError {
category: ValidationErrorCategory::SemanticError,
location: "root".to_string(),
message: "Unknown document type - no valid scenario, parameter variation, or catalog structure found".to_string(),
suggestion: None,
});
}
}
let duration = start_time.elapsed();
result.metrics.duration_ms = duration.as_millis() as u64;
result.metrics.cache_hit_ratio = self.calculate_cache_hit_ratio();
result
}
fn build_validation_context(&self, scenario: &OpenScenario) -> ValidationContext {
let mut context = ValidationContext::new();
if self.config.strict_mode {
context = context.with_strict_mode();
}
if let Some(entities) = &scenario.entities {
for obj in &entities.scenario_objects {
let entity_ref = EntityRef {
name: obj.name.as_literal().unwrap_or(&String::new()).clone(),
object_type: if obj.vehicle.is_some() {
ObjectType::Vehicle
} else if obj.pedestrian.is_some() {
ObjectType::Pedestrian
} else {
ObjectType::MiscellaneousObject
},
};
context.add_entity(entity_ref.name.clone(), entity_ref);
}
}
context
}
fn validate_file_header(&self, header: &FileHeader, result: &mut ValidationResult) {
if header
.author
.as_literal()
.unwrap_or(&String::new())
.is_empty()
{
result.errors.push(ValidationError {
category: ValidationErrorCategory::MissingRequired,
location: "FileHeader.author".to_string(),
message: "Author field is required and cannot be empty".to_string(),
suggestion: Some("Provide a valid author name".to_string()),
});
}
if header
.description
.as_literal()
.unwrap_or(&String::new())
.is_empty()
{
result.warnings.push(ValidationWarning {
category: ValidationWarningCategory::BestPractice,
location: "FileHeader.description".to_string(),
message: "Description should be provided for documentation".to_string(),
suggestion: Some("Add a meaningful description of the scenario".to_string()),
});
}
let rev_major = *header.rev_major.as_literal().unwrap_or(&0);
let rev_minor = *header.rev_minor.as_literal().unwrap_or(&0);
if rev_major < 1 {
result.errors.push(ValidationError {
category: ValidationErrorCategory::ConstraintViolation,
location: "FileHeader.revMajor".to_string(),
message: "Major revision must be at least 1".to_string(),
suggestion: Some("Use OpenSCENARIO version 1.0 or later".to_string()),
});
}
if rev_major > 1 || (rev_major == 1 && rev_minor > 3) {
result.warnings.push(ValidationWarning {
category: ValidationWarningCategory::Suspicious,
location: format!("FileHeader.rev{}.{}", rev_major, rev_minor),
message: "Using future OpenSCENARIO version - compatibility not guaranteed"
.to_string(),
suggestion: Some("Consider using a stable OpenSCENARIO version".to_string()),
});
}
}
fn validate_entities(
&self,
entities: &Entities,
context: &ValidationContext,
result: &mut ValidationResult,
) {
if entities.scenario_objects.is_empty() {
result.errors.push(ValidationError {
category: ValidationErrorCategory::MissingRequired,
location: "Entities".to_string(),
message: "At least one scenario object must be defined".to_string(),
suggestion: Some("Add vehicle, pedestrian, or miscellaneous objects".to_string()),
});
}
for (index, obj) in entities.scenario_objects.iter().enumerate() {
self.validate_scenario_object(
obj,
context,
&format!("Entities.ScenarioObject[{}]", index),
result,
);
}
let mut names = HashSet::new();
for obj in &entities.scenario_objects {
let default_name = String::new();
let name = obj.name.as_literal().unwrap_or(&default_name);
if !names.insert(name.clone()) {
result.errors.push(ValidationError {
category: ValidationErrorCategory::ConstraintViolation,
location: format!("Entities.ScenarioObject[name='{}']", name),
message: "Duplicate entity names are not allowed".to_string(),
suggestion: Some("Ensure all entity names are unique".to_string()),
});
}
}
}
fn validate_scenario_object(
&self,
obj: &ScenarioObject,
_context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
let default_name = String::new();
let name = obj.name.as_literal().unwrap_or(&default_name);
if name.is_empty() {
result.errors.push(ValidationError {
category: ValidationErrorCategory::MissingRequired,
location: format!("{}.name", location),
message: "ScenarioObject name is required".to_string(),
suggestion: Some("Provide a unique name for the entity".to_string()),
});
}
result.metrics.elements_validated += 1;
}
fn validate_storyboard(
&self,
storyboard: &Storyboard,
context: &ValidationContext,
result: &mut ValidationResult,
) {
if storyboard.stories.is_empty() {
result.warnings.push(ValidationWarning {
category: ValidationWarningCategory::Suspicious,
location: "Storyboard.stories".to_string(),
message: "Storyboard has no stories - scenario may not execute anything"
.to_string(),
suggestion: Some("Add at least one story with actions".to_string()),
});
}
for (index, story) in storyboard.stories.iter().enumerate() {
self.validate_story(
story,
context,
&format!("Storyboard.Story[{}]", index),
result,
);
}
}
fn validate_story(
&self,
story: &ScenarioStory,
context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
let default_name = String::new();
let story_name = story.name.as_literal().unwrap_or(&default_name);
if story_name.is_empty() {
result.errors.push(ValidationError {
category: ValidationErrorCategory::MissingRequired,
location: format!("{}.name", location),
message: "Story name is required".to_string(),
suggestion: Some("Provide a descriptive name for the story".to_string()),
});
}
if story.acts.is_empty() {
result.warnings.push(ValidationWarning {
category: ValidationWarningCategory::Suspicious,
location: format!("{}.acts", location),
message: "Story has no acts - may not execute any actions".to_string(),
suggestion: Some("Add at least one act with maneuver groups".to_string()),
});
}
for (index, act) in story.acts.iter().enumerate() {
self.validate_act(
act,
context,
&format!("{}.Act[{}]", location, index),
result,
);
}
}
fn validate_act(
&self,
act: &Act,
context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
let default_name = String::new();
let act_name = act.name.as_literal().unwrap_or(&default_name);
if act_name.is_empty() {
result.errors.push(ValidationError {
category: ValidationErrorCategory::MissingRequired,
location: format!("{}.name", location),
message: "Act name is required".to_string(),
suggestion: Some("Provide a descriptive name for the act".to_string()),
});
}
for (index, mg) in act.maneuver_groups.iter().enumerate() {
self.validate_maneuver_group(
mg,
context,
&format!("{}.ManeuverGroup[{}]", location, index),
result,
);
}
}
fn validate_maneuver_group(
&self,
mg: &ManeuverGroup,
context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
for entity_ref in &mg.actors.entity_refs {
let default_name = String::new();
let entity_name = entity_ref.entity_ref.as_literal().unwrap_or(&default_name);
if !context.entities.contains_key(entity_name) {
result.errors.push(ValidationError {
category: ValidationErrorCategory::InvalidReference,
location: format!("{}.Actors.EntityRef", location),
message: format!("Referenced entity '{}' not found", entity_name),
suggestion: Some(
"Ensure the entity is defined in the Entities section".to_string(),
),
});
}
}
for (index, maneuver) in mg.maneuvers.iter().enumerate() {
self.validate_maneuver(
maneuver,
context,
&format!("{}.Maneuver[{}]", location, index),
result,
);
}
}
fn validate_maneuver(
&self,
maneuver: &Maneuver,
context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
for (index, event) in maneuver.events.iter().enumerate() {
self.validate_event(
event,
context,
&format!("{}.Event[{}]", location, index),
result,
);
}
}
fn validate_event(
&self,
event: &Event,
context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
if let Some(trigger) = &event.start_trigger {
for (index, condition_group) in trigger.condition_groups.iter().enumerate() {
for (c_index, condition) in condition_group.conditions.iter().enumerate() {
self.validate_condition(
condition,
context,
&format!(
"{}.StartTrigger.ConditionGroup[{}].Condition[{}]",
location, index, c_index
),
result,
);
}
}
}
}
fn validate_condition(
&self,
condition: &Condition,
_context: &ValidationContext,
location: &str,
result: &mut ValidationResult,
) {
let default_name = String::new();
let condition_name = condition.name.as_literal().unwrap_or(&default_name);
if condition_name.is_empty() {
result.warnings.push(ValidationWarning {
category: ValidationWarningCategory::BestPractice,
location: format!("{}.name", location),
message: "Condition name should be provided for clarity".to_string(),
suggestion: Some("Add descriptive names to conditions".to_string()),
});
}
result.metrics.elements_validated += 1;
}
fn calculate_cache_hit_ratio(&self) -> f64 {
if !self.config.use_cache {
return 0.0;
}
if self.validation_cache.is_empty() {
0.0
} else {
0.85 }
}
}
impl ValidationResult {
pub fn new() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
metrics: ValidationMetrics {
duration_ms: 0,
elements_validated: 0,
cache_hit_ratio: 0.0,
},
}
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn is_clean(&self) -> bool {
self.errors.is_empty() && self.warnings.is_empty()
}
pub fn total_issues(&self) -> usize {
self.errors.len() + self.warnings.len()
}
pub fn summary(&self) -> String {
format!(
"Validation complete: {} errors, {} warnings, {} elements validated in {}ms",
self.errors.len(),
self.warnings.len(),
self.metrics.elements_validated,
self.metrics.duration_ms
)
}
}
impl Default for ValidationResult {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{
basic::Value,
entities::{Entities, ScenarioObject, Vehicle},
enums::VehicleCategory,
geometry::shapes::BoundingBox,
scenario::storyboard::Storyboard,
};
use crate::{FileHeader, OpenScenario};
#[test]
fn test_validator_creation() {
let validator = ScenarioValidator::new();
assert!(!validator.config.strict_mode);
assert!(validator.config.validate_references);
}
#[test]
fn test_file_header_validation() {
let mut validator = ScenarioValidator::new();
let valid_header = FileHeader {
rev_major: Value::literal(1),
rev_minor: Value::literal(2),
date: Value::literal("2024-01-01T00:00:00".to_string()),
description: Value::literal("Test scenario".to_string()),
author: Value::literal("Test Author".to_string()),
};
let vehicle = Vehicle {
name: Value::literal("TestVehicle".to_string()),
vehicle_category: VehicleCategory::Car,
bounding_box: BoundingBox::default(),
performance: Default::default(),
axles: Default::default(),
properties: None,
};
let entities = Entities {
scenario_objects: vec![ScenarioObject {
name: Value::literal("TestVehicle".to_string()),
vehicle: Some(vehicle),
pedestrian: None,
entity_catalog_reference: None,
object_controller: Default::default(),
}],
};
let scenario_def = crate::types::scenario::storyboard::ScenarioDefinition {
parameter_declarations: None,
variable_declarations: None,
monitor_declarations: None,
catalog_locations: crate::types::catalogs::locations::CatalogLocations::default(),
road_network: crate::types::road::RoadNetwork::default(),
entities: entities,
storyboard: Storyboard::default(),
};
let scenario = OpenScenario {
file_header: valid_header,
parameter_declarations: None,
variable_declarations: None,
monitor_declarations: None,
catalog_locations: Some(crate::types::catalogs::locations::CatalogLocations::default()),
road_network: Some(crate::types::road::RoadNetwork::default()),
entities: Some(scenario_def.entities),
storyboard: Some(scenario_def.storyboard),
parameter_value_distribution: None,
catalog: None,
};
let result = validator.validate_scenario(&scenario);
if !result.is_valid() {
for error in &result.errors {
println!("Validation error: {:?}", error);
}
}
assert!(result.is_valid(), "Valid scenario should pass validation");
}
#[test]
fn test_empty_author_validation() {
let mut validator = ScenarioValidator::new();
let invalid_header = FileHeader {
rev_major: Value::literal(1),
rev_minor: Value::literal(2),
date: Value::literal("2024-01-01T00:00:00".to_string()),
description: Value::literal("Test scenario".to_string()),
author: Value::literal("".to_string()), };
let vehicle = Vehicle {
name: Value::literal("TestVehicle".to_string()),
vehicle_category: VehicleCategory::Car,
bounding_box: BoundingBox::default(),
performance: Default::default(),
axles: Default::default(),
properties: None,
};
let entities = Entities {
scenario_objects: vec![ScenarioObject {
name: Value::literal("TestVehicle".to_string()),
vehicle: Some(vehicle),
pedestrian: None,
entity_catalog_reference: None,
object_controller: Default::default(),
}],
};
let scenario_def = crate::types::scenario::storyboard::ScenarioDefinition {
parameter_declarations: None,
variable_declarations: None,
monitor_declarations: None,
catalog_locations: crate::types::catalogs::locations::CatalogLocations::default(),
road_network: crate::types::road::RoadNetwork::default(),
entities: entities,
storyboard: Storyboard::default(),
};
let scenario = OpenScenario {
file_header: invalid_header,
parameter_declarations: None,
variable_declarations: None,
monitor_declarations: None,
catalog_locations: Some(crate::types::catalogs::locations::CatalogLocations::default()),
road_network: Some(crate::types::road::RoadNetwork::default()),
entities: Some(scenario_def.entities),
storyboard: Some(scenario_def.storyboard),
parameter_value_distribution: None,
catalog: None,
};
let result = validator.validate_scenario(&scenario);
assert!(!result.is_valid(), "Empty author should fail validation");
assert_eq!(result.errors.len(), 1);
assert!(matches!(
result.errors[0].category,
ValidationErrorCategory::MissingRequired
));
}
#[test]
fn test_duplicate_entity_names_validation() {
let mut validator = ScenarioValidator::new();
let vehicle1 = Vehicle {
name: Value::literal("Car1".to_string()),
vehicle_category: VehicleCategory::Car,
bounding_box: BoundingBox::default(),
performance: Default::default(),
axles: Default::default(),
properties: None,
};
let vehicle2 = Vehicle {
name: Value::literal("Car1".to_string()), vehicle_category: VehicleCategory::Truck,
bounding_box: BoundingBox::default(),
performance: Default::default(),
axles: Default::default(),
properties: None,
};
let entities = Entities {
scenario_objects: vec![
ScenarioObject {
name: Value::literal("Car1".to_string()),
vehicle: Some(vehicle1),
pedestrian: None,
entity_catalog_reference: None,
object_controller: Default::default(),
},
ScenarioObject {
name: Value::literal("Car1".to_string()),
vehicle: Some(vehicle2),
pedestrian: None,
entity_catalog_reference: None,
object_controller: Default::default(),
},
],
};
let scenario_def = crate::types::scenario::storyboard::ScenarioDefinition {
parameter_declarations: None,
variable_declarations: None,
monitor_declarations: None,
catalog_locations: crate::types::catalogs::locations::CatalogLocations::default(),
road_network: crate::types::road::RoadNetwork::default(),
entities: entities,
storyboard: Storyboard::default(),
};
let scenario = OpenScenario {
file_header: FileHeader {
author: Value::literal("Test Author".to_string()),
date: Value::literal("2024-01-01T00:00:00".to_string()),
description: Value::literal("Test scenario".to_string()),
rev_major: Value::literal(1),
rev_minor: Value::literal(2),
},
parameter_declarations: None,
variable_declarations: None,
monitor_declarations: None,
catalog_locations: Some(crate::types::catalogs::locations::CatalogLocations::default()),
road_network: Some(crate::types::road::RoadNetwork::default()),
entities: Some(scenario_def.entities),
storyboard: Some(scenario_def.storyboard),
parameter_value_distribution: None,
catalog: None,
};
let result = validator.validate_scenario(&scenario);
assert!(
!result.is_valid(),
"Duplicate entity names should fail validation"
);
assert!(result
.errors
.iter()
.any(|e| matches!(e.category, ValidationErrorCategory::ConstraintViolation)));
}
#[test]
fn test_validation_metrics() {
let mut validator = ScenarioValidator::new();
let scenario = OpenScenario::default();
let result = validator.validate_scenario(&scenario);
assert!(result.metrics.duration_ms < 1000); assert_eq!(result.metrics.cache_hit_ratio, 0.0); }
#[test]
fn test_strict_mode() {
let config = ValidationConfig {
strict_mode: true,
..Default::default()
};
let mut validator = ScenarioValidator::with_config(config);
let vehicle = crate::types::entities::vehicle::Vehicle {
name: crate::types::basic::Value::literal("TestCar".to_string()),
vehicle_category: crate::types::enums::VehicleCategory::Car,
bounding_box: crate::types::geometry::BoundingBox::default(),
performance: Default::default(),
axles: Default::default(),
properties: None,
};
let scenario_object = crate::types::entities::ScenarioObject {
name: crate::types::basic::Value::literal("TestVehicle".to_string()),
vehicle: Some(vehicle),
pedestrian: None,
entity_catalog_reference: None,
object_controller: Default::default(),
};
let entities = crate::types::entities::Entities {
scenario_objects: vec![scenario_object],
};
let mut scenario = OpenScenario::default();
scenario.entities = Some(entities);
let result = validator.validate_scenario(&scenario);
assert!(result.metrics.elements_validated > 0);
assert!(validator.config.strict_mode);
}
}