use crate::EsmFile;
use crate::parse::{LoadOptions, load_string, load_string_with_options};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub schema_errors: Vec<SchemaError>,
pub structural_errors: Vec<StructuralError>,
pub unit_warnings: Vec<UnitWarning>,
pub is_valid: bool,
}
impl ValidationResult {
pub fn has_errors(&self) -> bool {
!self.schema_errors.is_empty() || !self.structural_errors.is_empty()
}
#[deprecated(note = "read the structural_errors / schema_errors fields directly")]
pub fn errors(&self) -> Vec<StructuralError> {
self.structural_errors.clone()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct UnitWarning {
pub path: String,
pub code: String,
pub message: String,
pub lhs_units: String,
pub rhs_units: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaError {
pub path: String,
pub message: String,
pub keyword: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuralError {
pub path: String,
pub code: StructuralErrorCode,
pub message: String,
pub details: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StructuralErrorCode {
UndefinedVariable,
EquationCountMismatch,
UndefinedSpecies,
UndefinedParameter,
NullReaction,
EquationDefinesParameter,
UnboundIndexSymbol,
EventAffectsParameter,
UnresolvedScopedRef,
EventVarUndeclared,
UndefinedOperator,
DataSourceUndefined,
UndefinedSystem,
OperatorVariableMissing,
CircularDependency,
UnitInconsistency,
UnitParseError,
IcInReactionSystem,
FactorWithExpressionTransform,
UnresolvedSubsystemRef,
AmbiguousSubsystemRef,
MountFormUnsupported,
DomainUnitMismatch,
JoinKeyInvalidType,
JoinSideAmbiguous,
JoinSymsUnknownSymbol,
RaggedValuesNotGathered,
RelationalNodeInContinuous,
RecurrenceNotWellfounded,
RecurrenceUnsupportedForm,
UndefinedIndexSet,
InvalidBroadcastFn,
ArrayShapeMismatch,
ObservedCycle,
ReservedVariableName,
UnknownOverrideKey,
AssertionRankMismatch,
ArrayDefaultWithoutShape,
}
use crate::diagnostic::codes;
impl std::fmt::Display for StructuralErrorCode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::UndefinedVariable => codes::UNDEFINED_VARIABLE,
Self::EquationCountMismatch => codes::EQUATION_COUNT_MISMATCH,
Self::UndefinedSpecies => codes::UNDEFINED_SPECIES,
Self::UndefinedParameter => codes::UNDEFINED_PARAMETER,
Self::NullReaction => codes::NULL_REACTION,
Self::EquationDefinesParameter => codes::EQUATION_DEFINES_PARAMETER,
Self::UnboundIndexSymbol => codes::UNBOUND_INDEX_SYMBOL,
Self::EventAffectsParameter => codes::EVENT_AFFECTS_PARAMETER,
Self::UnresolvedScopedRef => codes::UNRESOLVED_SCOPED_REF,
Self::EventVarUndeclared => codes::EVENT_VAR_UNDECLARED,
Self::UndefinedOperator => codes::UNDEFINED_OPERATOR,
Self::DataSourceUndefined => codes::DATA_SOURCE_UNDEFINED,
Self::UndefinedSystem => codes::UNDEFINED_SYSTEM,
Self::OperatorVariableMissing => codes::OPERATOR_VARIABLE_MISSING,
Self::CircularDependency => codes::CIRCULAR_DEPENDENCY,
Self::UnitInconsistency => codes::UNIT_INCONSISTENCY,
Self::UnitParseError => codes::UNIT_PARSE_ERROR,
Self::IcInReactionSystem => codes::IC_IN_REACTION_SYSTEM,
Self::FactorWithExpressionTransform => codes::FACTOR_WITH_EXPRESSION_TRANSFORM,
Self::UnresolvedSubsystemRef => codes::UNRESOLVED_SUBSYSTEM_REF,
Self::AmbiguousSubsystemRef => codes::AMBIGUOUS_SUBSYSTEM_REF,
Self::MountFormUnsupported => codes::MOUNT_FORM_UNSUPPORTED,
Self::DomainUnitMismatch => codes::DOMAIN_UNIT_MISMATCH,
Self::JoinKeyInvalidType => codes::JOIN_KEY_INVALID_TYPE,
Self::JoinSideAmbiguous => codes::JOIN_SIDE_AMBIGUOUS,
Self::JoinSymsUnknownSymbol => codes::JOIN_SYMS_UNKNOWN_SYMBOL,
Self::RelationalNodeInContinuous => codes::RELATIONAL_NODE_IN_CONTINUOUS,
Self::RaggedValuesNotGathered => codes::RAGGED_VALUES_NOT_GATHERED,
Self::RecurrenceNotWellfounded => codes::RECURRENCE_NOT_WELLFOUNDED,
Self::RecurrenceUnsupportedForm => codes::RECURRENCE_UNSUPPORTED_FORM,
Self::UndefinedIndexSet => codes::UNDEFINED_INDEX_SET,
Self::InvalidBroadcastFn => codes::INVALID_BROADCAST_FN,
Self::ArrayShapeMismatch => codes::ARRAY_SHAPE_MISMATCH,
Self::ObservedCycle => codes::OBSERVED_CYCLE,
Self::ReservedVariableName => codes::RESERVED_VARIABLE_NAME,
Self::UnknownOverrideKey => codes::UNKNOWN_OVERRIDE_KEY,
Self::AssertionRankMismatch => codes::ASSERTION_RANK_MISMATCH,
Self::ArrayDefaultWithoutShape => codes::ARRAY_DEFAULT_WITHOUT_SHAPE,
};
write!(f, "{s}")
}
}
pub fn validate(esm_file: &EsmFile) -> ValidationResult {
validate_scoped(esm_file, ValidationScope::Full)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ValidationScope {
Full,
References,
}
pub(crate) fn validate_scoped(esm_file: &EsmFile, scope: ValidationScope) -> ValidationResult {
let schema_errors = Vec::new();
let mut structural_errors = Vec::new();
let mut unit_warnings = Vec::new();
let system_refs = build_system_reference_map(esm_file);
let document_units = crate::units::document_scoped_units(esm_file);
if let Some(ref models) = esm_file.models {
for (model_name, model) in models {
crate::structural::validate_model(
esm_file,
model_name,
model,
&system_refs,
&document_units,
scope,
&mut structural_errors,
&mut unit_warnings,
);
}
if scope == ValidationScope::Full {
crate::structural::check_circular_dependencies_in_models(
models,
&mut structural_errors,
);
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (rs_name, rs) in reaction_systems {
crate::structural::validate_reaction_system(
esm_file,
rs_name,
rs,
&system_refs,
&document_units,
&mut structural_errors,
);
}
}
crate::inline_test_checks::validate_inline_tests(esm_file, &mut structural_errors);
crate::structural::validate_data_source_references(esm_file, &mut structural_errors);
if let Some(ref coupling) = esm_file.coupling {
crate::coupling::validate_coupling(
coupling,
&system_refs,
esm_file,
&mut structural_errors,
);
}
crate::coupling::validate_imported_coupling(esm_file, &system_refs, &mut structural_errors);
let is_valid = schema_errors.is_empty() && structural_errors.is_empty();
ValidationResult {
schema_errors,
structural_errors,
unit_warnings,
is_valid,
}
}
pub fn validate_text(json_str: &str, base_path: Option<&std::path::Path>) -> ValidationResult {
let loaded = match base_path {
Some(base) => load_string_with_options(
json_str,
&LoadOptions {
base_path: Some(base.to_path_buf()),
..Default::default()
},
),
None => load_string(json_str),
};
match loaded {
Ok(esm_file) => {
validate_with_schema(json_str, &esm_file)
}
Err(e) => {
let schema_errors = match serde_json::from_str::<Value>(json_str) {
Ok(json_value) => {
let mut errs = crate::parse::collect_schema_errors(&json_value);
if errs.is_empty() {
errs.push(SchemaError {
path: "".to_string(),
message: format!("Failed to load ESM file: {e}"),
keyword: "parse".to_string(),
});
}
errs
}
Err(je) => vec![SchemaError {
path: "".to_string(),
message: format!("Invalid JSON: {je}"),
keyword: "format".to_string(),
}],
};
let structural_errors = match serde_json::from_str::<EsmFile>(json_str) {
Ok(esm_file) => validate(&esm_file).structural_errors,
Err(_) => vec![],
};
ValidationResult {
schema_errors,
structural_errors,
unit_warnings: vec![],
is_valid: false,
}
}
}
}
#[deprecated(
since = "0.2.0",
note = "renamed to `validate_text` (API_SPEC.md §8 item 13)"
)]
pub fn validate_complete(json_str: &str, base_path: Option<&std::path::Path>) -> ValidationResult {
validate_text(json_str, base_path)
}
pub fn validate_with_schema(json_str: &str, esm_file: &EsmFile) -> ValidationResult {
let mut schema_errors = Vec::new();
let mut structural_errors = Vec::new();
let mut unit_warnings = Vec::new();
match serde_json::from_str::<Value>(json_str) {
Err(e) => {
schema_errors.push(SchemaError {
path: "".to_string(),
message: format!("Invalid JSON: {e}"),
keyword: "format".to_string(),
});
}
Ok(json_value) => {
schema_errors.extend(crate::parse::collect_schema_errors(&json_value));
}
}
let result = validate(esm_file);
structural_errors.extend(result.structural_errors);
unit_warnings.extend(result.unit_warnings);
let is_valid = schema_errors.is_empty() && structural_errors.is_empty();
ValidationResult {
schema_errors,
structural_errors,
unit_warnings,
is_valid,
}
}
pub(crate) fn build_system_reference_map(esm_file: &EsmFile) -> HashMap<String, SystemInfo> {
let mut systems = HashMap::new();
if let Some(ref models) = esm_file.models {
for (name, model) in models {
let variables: HashSet<String> = model.variables.keys().cloned().collect();
let parameters: HashSet<String> = model
.variables
.iter()
.filter(|(_, v)| v.var_type == crate::VariableType::Parameter)
.map(|(k, _)| k.clone())
.collect();
systems.insert(
name.clone(),
SystemInfo {
variables,
species: HashSet::new(),
parameters,
},
);
register_subsystems(name, model.subsystems.as_ref(), &mut systems);
}
}
if let Some(ref reaction_systems) = esm_file.reaction_systems {
for (name, rs) in reaction_systems {
let species: HashSet<String> = rs.species.keys().cloned().collect();
let parameters: HashSet<String> = rs.parameters.keys().cloned().collect();
systems.insert(
name.clone(),
SystemInfo {
variables: HashSet::new(),
species,
parameters,
},
);
register_subsystems(name, rs.subsystems.as_ref(), &mut systems);
}
}
if let Some(ref operators) = esm_file.operators {
for name in operators.keys() {
systems.insert(
name.clone(),
SystemInfo {
variables: HashSet::new(),
species: HashSet::new(),
parameters: HashSet::new(),
},
);
}
}
systems
}
fn register_subsystems(
prefix: &str,
subsystems: Option<&indexmap::IndexMap<String, serde_json::Value>>,
systems: &mut HashMap<String, SystemInfo>,
) {
let Some(subsystems) = subsystems else {
return;
};
for (child_name, child) in subsystems {
let path = format!("{prefix}.{child_name}");
let variables: HashSet<String> = child
.get("variables")
.and_then(|v| v.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
let species: HashSet<String> = child
.get("species")
.and_then(|v| v.as_object())
.map(|m| m.keys().cloned().collect())
.unwrap_or_default();
let mut parameters: HashSet<String> = child
.get("variables")
.and_then(|v| v.as_object())
.map(|m| {
m.iter()
.filter(|(_, v)| v.get("type").and_then(|t| t.as_str()) == Some("parameter"))
.map(|(k, _)| k.clone())
.collect()
})
.unwrap_or_default();
if let Some(params) = child.get("parameters").and_then(|v| v.as_object()) {
parameters.extend(params.keys().cloned());
}
systems.insert(
path.clone(),
SystemInfo {
variables,
species,
parameters,
},
);
if let Some(nested) = child.get("subsystems").and_then(|v| v.as_object()) {
let nested: indexmap::IndexMap<String, serde_json::Value> =
nested.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
register_subsystems(&path, Some(&nested), systems);
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SystemInfo {
pub(crate) variables: HashSet<String>,
pub(crate) species: HashSet<String>,
pub(crate) parameters: HashSet<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{ddt, test_file, var};
use crate::types::{Equation, ExpressionNode, Metadata, ModelVariable, VariableType};
use crate::{Expr, Model};
use indexmap::IndexMap;
#[test]
fn test_validate_empty_file() {
let esm_file = test_file();
let result = validate(&esm_file);
assert!(result.is_valid);
assert!(result.structural_errors.is_empty());
assert!(result.schema_errors.is_empty());
}
#[test]
fn test_validate_model_with_undefined_variable() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert("x".to_string(), var(VariableType::Unknown, None));
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![ddt("x", Expr::Variable("undefined_var".to_string()))],
..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(!result.is_valid);
assert!(!result.structural_errors.is_empty());
assert!(
result.structural_errors[0]
.message
.contains("Variable 'undefined_var' referenced in equation is not declared")
);
assert!(matches!(
result.structural_errors[0].code,
StructuralErrorCode::UndefinedVariable
));
}
#[test]
fn test_equation_count_mismatch() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert("x".to_string(), var(VariableType::Unknown, None));
variables.insert("y".to_string(), var(VariableType::Unknown, None));
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![ddt("x", Expr::Variable("x".to_string()))],
..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(!result.is_valid);
assert!(!result.structural_errors.is_empty());
let error = &result.structural_errors[0];
assert!(matches!(
error.code,
StructuralErrorCode::EquationCountMismatch
));
assert!(
error
.message
.contains("Number of equations (1) does not match number of unknowns (2)"),
"unexpected message: {}",
error.message
);
}
#[test]
fn test_validation_result_structure() {
let esm_file = test_file();
let result = validate(&esm_file);
assert!(result.is_valid);
assert!(result.schema_errors.is_empty());
assert!(result.structural_errors.is_empty());
assert!(result.unit_warnings.is_empty());
}
#[test]
fn test_unknown_without_equation() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert("total".to_string(), var(VariableType::Unknown, None));
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![], ..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(!result.is_valid);
assert_eq!(result.structural_errors.len(), 1);
let finding = &result.structural_errors[0];
assert!(matches!(
finding.code,
StructuralErrorCode::EquationCountMismatch
));
assert_eq!(
finding.message,
"Number of equations (0) does not match number of unknowns (1)"
);
assert_eq!(
finding.details["missing_equations_for"],
serde_json::json!(["total"])
);
assert_eq!(finding.details["unknowns"], serde_json::json!(["total"]));
}
#[test]
fn test_observed_variable_with_expression() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert(
"x".to_string(),
ModelVariable {
default: Some(1.0.into()),
..var(VariableType::Unknown, Some("m"))
},
);
variables.insert(
"k".to_string(),
ModelVariable {
default: Some(0.1.into()),
..var(VariableType::Parameter, Some("1/s"))
},
);
variables.insert(
"rate".to_string(),
ModelVariable {
description: Some("Rate of change".to_string()),
..var(VariableType::Unknown, Some("m/s"))
},
);
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![
ddt("x", Expr::Variable("rate".to_string())),
Equation {
comment: None,
lhs: Expr::Variable("rate".to_string()),
rhs: Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![
Expr::Variable("k".to_string()),
Expr::Variable("x".to_string()),
],
..Default::default()
}),
},
],
..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(
result.is_valid,
"Validation failed: {:?}",
result.structural_errors
);
assert!(result.structural_errors.is_empty());
}
#[test]
fn test_json_serialization_with_observed_expression() {
let json_str = r#"{
"esm": "1.0.0",
"metadata": {
"name": "TestModel",
"description": "Test observed variables with expressions"
},
"models": {
"TestModel": {
"variables": {
"x": { "type": "unknown", "units": "m", "default": 1.0 },
"k": { "type": "parameter", "units": "1/s", "default": 0.1 },
"rate": {
"type": "unknown",
"units": "m/s",
"description": "Rate of change"
}
},
"equations": [
{
"lhs": { "op": "D", "args": ["x"], "wrt": "t" },
"rhs": "rate"
},
{
"lhs": "rate",
"rhs": { "op": "*", "args": ["k", "x"] }
}
]
}
}
}"#;
let esm_file: EsmFile = serde_json::from_str(json_str).expect("Failed to parse JSON");
let result = validate(&esm_file);
assert!(
result.is_valid,
"Validation should pass: {:?}",
result.structural_errors
);
let model = esm_file.models.as_ref().unwrap().get("TestModel").unwrap();
let rate_var = model.variables.get("rate").unwrap();
assert_eq!(rate_var.var_type, VariableType::Unknown);
assert_eq!(crate::classification::observed_unknowns(model), ["rate"]);
assert!(
crate::classification::observed_definitions(model).contains_key("rate"),
"the observed unknown's definition is its equation's RHS"
);
let serialized =
serde_json::to_string_pretty(&esm_file).expect("Failed to serialize to JSON");
let _reparsed: EsmFile =
serde_json::from_str(&serialized).expect("Failed to reparse serialized JSON");
}
#[test]
fn test_unit_validation() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert(
"x".to_string(),
ModelVariable {
default: Some(1.0.into()),
..var(VariableType::Unknown, Some("m"))
},
);
variables.insert(
"k".to_string(),
ModelVariable {
default: Some(0.1.into()),
..var(VariableType::Parameter, Some("1/s"))
},
);
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![ddt(
"x",
Expr::operator(ExpressionNode {
op: "*".to_string(),
args: vec![
Expr::Variable("k".to_string()),
Expr::Variable("x".to_string()),
],
..Default::default()
}),
)],
..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(
result.is_valid,
"Validation should pass: {:?}",
result.structural_errors
);
assert!(result.structural_errors.is_empty());
assert!(
result.unit_warnings.is_empty(),
"Unit warnings: {:?}",
result.unit_warnings
);
}
#[test]
fn test_unit_validation_mismatch() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert(
"x".to_string(),
ModelVariable {
default: Some(1.0.into()),
..var(VariableType::Unknown, Some("m"))
},
);
variables.insert(
"k".to_string(),
ModelVariable {
default_units: None,
default: Some(0.1.into()),
..var(VariableType::Parameter, Some("kg")) },
);
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![ddt("x", Expr::Variable("k".to_string()))],
..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(
!result.is_valid,
"An unreconcilable derivative equation must fail validation: {result:?}"
);
let unit_errors: Vec<_> = result
.structural_errors
.iter()
.filter(|e| matches!(e.code, StructuralErrorCode::UnitInconsistency))
.collect();
assert_eq!(unit_errors.len(), 1, "{:?}", result.structural_errors);
assert_eq!(unit_errors[0].path, "/models/test/equations/0");
}
#[test]
fn test_unit_validation_integration() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert(
"position".to_string(),
ModelVariable {
default: Some(0.0.into()),
..var(VariableType::Unknown, Some("m"))
},
);
variables.insert(
"velocity".to_string(),
ModelVariable {
default: Some(1.0.into()),
..var(VariableType::Parameter, Some("m/s"))
},
);
models.insert(
"test_model".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![ddt("position", Expr::Variable("velocity".to_string()))],
..Default::default()
},
);
let esm_file = EsmFile {
metadata: Metadata {
name: Some("Unit Test".to_string()),
..Default::default()
},
models: Some(models),
..Default::default()
};
let result = validate(&esm_file);
assert!(
result.is_valid,
"Validation should pass: {:?}",
result.structural_errors
);
assert!(result.structural_errors.is_empty());
assert!(
result.unit_warnings.is_empty(),
"No unit warnings expected: {:?}",
result.unit_warnings
);
}
#[test]
fn test_transcendental_function_units() {
let mut models = IndexMap::new();
let mut variables = IndexMap::new();
variables.insert(
"x".to_string(),
ModelVariable {
default: Some(1.0.into()),
..var(VariableType::Unknown, Some("m"))
},
);
models.insert(
"test".to_string(),
Model {
name: Some("Test Model".to_string()),
variables,
equations: vec![ddt(
"x",
Expr::operator(ExpressionNode {
op: "exp".to_string(),
args: vec![Expr::Variable("x".to_string())],
..Default::default()
}),
)],
..Default::default()
},
);
let esm_file = EsmFile {
models: Some(models),
..test_file()
};
let result = validate(&esm_file);
assert!(
!result.is_valid,
"A dimensional argument to exp must fail validation: {result:?}"
);
let unit_errors: Vec<_> = result
.structural_errors
.iter()
.filter(|e| matches!(e.code, StructuralErrorCode::UnitInconsistency))
.collect();
assert_eq!(unit_errors.len(), 2, "{:?}", result.structural_errors);
assert!(
unit_errors.iter().any(|e| e
.message
.contains("Argument to 'exp' must be dimensionless")),
"{unit_errors:?}"
);
assert!(
unit_errors
.iter()
.any(|e| e.message.contains("No time unit can reconcile")),
"{unit_errors:?}"
);
assert!(
unit_errors
.iter()
.all(|e| e.path == "/models/test/equations/0")
);
}
#[test]
fn test_validate_vs_validate_text() {
let esm_file = test_file();
let invalid_json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "test"
},
"models": {
"test_model": {
"variables": {
"x": {
"type": "invalid_type_that_should_fail_schema"
}
},
"equations": []
}
}
}
"#;
let result1 = validate(&esm_file);
let result2 = validate_text(invalid_json, None);
assert!(
result1.schema_errors.is_empty(),
"validate() should have empty schema_errors because it only does structural validation"
);
assert!(
result1.is_valid,
"validate() should pass structural validation on valid ESM structure"
);
assert!(
!result2.schema_errors.is_empty(),
"validate_text() should find schema errors"
);
assert!(
!result2.is_valid,
"validate_text() should fail due to schema errors"
);
println!(
"CORRECT BEHAVIOR: validate() found {} schema errors, validate_text() found {} schema errors",
result1.schema_errors.len(),
result2.schema_errors.len()
);
}
#[test]
fn test_validate_text_with_schema_errors() {
let invalid_json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "test"
},
"models": {
"test_model": {
"variables": {
"x": {
"type": "invalid_type_that_should_fail_schema"
}
},
"equations": []
}
}
}
"#;
let result = validate_text(invalid_json, None);
assert!(
!result.is_valid,
"validate_text should detect schema validation failures"
);
assert!(
!result.schema_errors.is_empty(),
"validate_text should find schema errors"
);
assert!(
result
.schema_errors
.iter()
.any(|e| e.keyword == "enum" && e.path == "/models/test_model/variables/x/type"),
"expected a per-error `enum` schema violation at the variable's type pointer; got {:?}",
result.schema_errors
);
assert!(
result.schema_errors.iter().all(|e| e.keyword != "parse"),
"schema errors must be enumerated per-violation, not a single `parse` blob: {:?}",
result.schema_errors
);
}
#[test]
fn test_validate_text_with_valid_json() {
let valid_json = r#"
{
"esm": "1.0.0",
"metadata": {
"name": "test"
},
"models": {
"test_model": {
"variables": {
"x": {
"type": "unknown",
"units": "m",
"default": 1.0
}
},
"equations": [
{
"lhs": {
"op": "D",
"args": [
"x"
],
"wrt": "t"
},
"rhs": {
"op": "*",
"args": [
0.1,
"x"
]
}
}
]
}
}
}
"#;
let result = validate_text(valid_json, None);
assert!(
result.is_valid,
"validate_text should pass with valid JSON: {result:?}"
);
assert!(
result.schema_errors.is_empty(),
"Should have no schema errors"
);
assert!(
result.structural_errors.is_empty(),
"Should have no structural errors"
);
}
#[test]
fn test_validate_text_recovers_structural_on_load_reject() {
let fixture = include_str!("../../../tests/invalid/event_var_undeclared.esm");
let result = validate_text(fixture, None);
assert!(!result.is_valid, "load-rejected fixture must be invalid");
assert!(
!result.structural_errors.is_empty(),
"validate_text must recover typed structural errors on load-reject, got none"
);
assert!(
result.structural_errors.iter().any(|e| matches!(
e.code,
StructuralErrorCode::EventVarUndeclared
) && e.path
== "/models/TestModel/continuous_events/0/affects/0/lhs"),
"expected pinned event_var_undeclared @ /models/TestModel/continuous_events/0/affects/0/lhs, got: {:?}",
result.structural_errors
);
}
fn doc_with_observed_expr(expr: &str) -> String {
format!(
r#"{{
"esm": "1.0.0",
"metadata": {{"name": "bcast"}},
"models": {{"M": {{
"variables": {{
"x": {{"type": "unknown", "units": "1", "default": 1.0}},
"y": {{"type": "unknown", "units": "1"}}
}},
"equations": [
{{"lhs": "y", "rhs": {expr}}},
{{"lhs": {{"op": "D", "args": ["x"], "wrt": "t"}}, "rhs": "y"}}
]
}}}}
}}"#
)
}
fn broadcast_findings(expr: &str) -> Vec<StructuralError> {
let file: EsmFile =
serde_json::from_str(&doc_with_observed_expr(expr)).expect("fixture deserializes");
let result = validate(&file);
result
.structural_errors
.into_iter()
.filter(|e| matches!(e.code, StructuralErrorCode::InvalidBroadcastFn))
.collect()
}
#[test]
fn unregistered_broadcast_fn_is_a_structural_error() {
let expr = r#"{"op": "broadcast", "fn": "not_a_real_op", "args": ["x"]}"#;
let file = crate::parse::load_string(&doc_with_observed_expr(expr)).expect("fixture loads");
let result = validate(&file);
assert!(
!result.is_valid,
"an unregistered `broadcast.fn` must fail validation"
);
let found = broadcast_findings(expr);
assert_eq!(found.len(), 1, "expected one finding, got {found:?}");
assert_eq!(found[0].path, "/models/M/equations/0/rhs");
assert!(
found[0].message.contains("not_a_real_op"),
"the message must name the offending fn: {}",
found[0].message
);
}
#[test]
fn missing_broadcast_fn_is_rejected_at_both_layers() {
let expr = r#"{"op": "broadcast", "args": ["x", "x"]}"#;
assert!(
crate::parse::load_string(&doc_with_observed_expr(expr)).is_err(),
"the schema requires `fn` on a `broadcast` node"
);
let found = broadcast_findings(expr);
assert_eq!(found.len(), 1, "expected one finding, got {found:?}");
assert_eq!(found[0].details["broadcast_fn"], serde_json::Value::Null);
}
#[test]
fn non_scalar_broadcast_fn_is_a_structural_error() {
for f in ["faq", "index", "broadcast", "makearray"] {
let expr = format!(r#"{{"op": "broadcast", "fn": "{f}", "args": ["x"]}}"#);
let found = broadcast_findings(&expr);
assert_eq!(
found.len(),
1,
"fn `{f}`: expected one finding, got {found:?}"
);
}
}
#[test]
fn broadcast_fn_arity_mismatch_is_a_structural_error() {
for (f, args) in [
("min", r#"["x"]"#),
("sin", r#"["x", "x"]"#),
("/", r#"["x"]"#),
] {
let expr = format!(r#"{{"op": "broadcast", "fn": "{f}", "args": {args}}}"#);
let found = broadcast_findings(&expr);
assert_eq!(
found.len(),
1,
"fn `{f}`: expected one finding, got {found:?}"
);
assert_eq!(found[0].details["broadcast_fn"], f);
}
}
#[test]
fn well_formed_broadcasts_produce_no_finding() {
for expr in [
r#"{"op": "broadcast", "fn": "exp", "args": ["x"]}"#,
r#"{"op": "broadcast", "fn": "-", "args": ["x"]}"#,
r#"{"op": "broadcast", "fn": "neg", "args": ["x"]}"#,
r#"{"op": "broadcast", "fn": "+", "args": ["x"]}"#,
r#"{"op": "broadcast", "fn": "+", "args": ["x", "x"]}"#,
r#"{"op": "broadcast", "fn": "min", "args": ["x", "x"]}"#,
r#"{"op": "broadcast", "fn": "*", "args": ["x", "x", "x"]}"#,
r#"{"op": "broadcast", "fn": "ifelse", "args": ["x", "x", "x"]}"#,
] {
assert!(
broadcast_findings(expr).is_empty(),
"`{expr}` must remain valid"
);
}
}
#[test]
fn broadcast_fn_is_checked_inside_sidecar_fields() {
let expr = r#"{"op": "faq", "output_idx": ["i"], "args": [],
"ranges": {"i": [1, 3]},
"expr": {"op": "broadcast", "fn": "nope", "args": ["x"]}}"#;
let found = broadcast_findings(expr);
assert_eq!(found.len(), 1, "expected one finding, got {found:?}");
assert_eq!(found[0].path, "/models/M/equations/0/rhs");
}
}