Files
OneCAD-Simulation/crates/engine/src/schema.rs
T

737 lines
28 KiB
Rust

//! Typed parameter schemas: what a command's JSON parameters are, field by field.
//!
//! A command's [`CommandSpec::form`](crate::CommandSpec::form) lists its [`Field`]s: name, kind,
//! default, whether it is required, its unit dimension, its choices, the model collection it
//! names, nested groups and one-of variants. The schema describes the JSON the command already
//! takes; it never changes it. The UI draws its command forms from it, `fieldcraft-cli commands
//! --json` and the MCP `list_commands` tool give it as JSON Schema ([`json_schema`]).
//!
//! The schema is descriptive: the command's `run` stays the judge of its parameters. What the
//! schema cannot express (a mass given per direction, a table of load-path points) stays reachable
//! through the form's JSON view.
use fieldcraft_model::UnitSystem;
use serde_json::{Map, Value, json};
use crate::Session;
/// Rows a list field holds at most in a form.
pub const MAX_ROWS: usize = 1000;
/// Names a reference list offers at most.
pub const MAX_NAMES: usize = 10_000;
/// One parameter of a command.
#[derive(Debug, Clone, Copy)]
pub struct Field {
/// Its JSON key. Empty for [`Kind::Variants`] whose tag and fields sit in the parent object
/// (an internally tagged definition such as a load: `{"name", "type": "force", "vector"}`).
pub key: &'static str,
pub label: &'static str,
pub kind: Kind,
pub required: bool,
/// The value the form starts with, as JSON text; `None` leaves the field empty.
pub default: Option<&'static str>,
/// What an empty field means ("every body", "1/25 of the diagonal"), or a short note.
pub hint: &'static str,
}
/// What a field holds.
#[derive(Debug, Clone, Copy)]
pub enum Kind {
/// A number in the model's unit system (or a fixed unit such as degrees).
Number {
dim: Dim,
min: Option<f64>,
max: Option<f64>,
},
/// A whole number in `min..=max`.
Integer {
min: i64,
max: i64,
},
Text,
/// A file path; `save` when the command writes it.
Path {
save: bool,
},
Bool,
/// One of these strings: (value, label).
Choice(&'static [(&'static str, &'static str)]),
/// A fixed-length list of numbers, one per label (`["x", "y", "z"]`); `nullable` entries may
/// be null (a free degree of freedom).
Vector {
dim: Dim,
labels: &'static [&'static str],
nullable: bool,
},
/// The name of an item of the model or the open design.
Ref(Collection),
/// A list of such names.
RefList(Collection),
/// A nested object.
Group(&'static [Field]),
/// A nested object (or, with an empty key, the parent object) tagged by `tag`: the tag's value
/// picks the variant whose fields apply.
Variants {
tag: &'static str,
variants: &'static [Variant],
},
/// An object with exactly one of these keys (`{"set": …}` or `{"reference_point": …}`), or a
/// bare string for a [`Kind::Unit`] option.
Either(&'static [Field]),
/// An option of [`Kind::Either`] written as its key alone (`"line"`).
Unit,
/// A list of objects with these fields, at most `max` long.
Rows {
fields: &'static [Field],
max: usize,
},
/// Any JSON: what the schema does not describe.
Json,
}
/// One variant of [`Kind::Variants`].
#[derive(Debug, Clone, Copy)]
pub struct Variant {
pub tag: &'static str,
pub label: &'static str,
pub fields: &'static [Field],
}
/// Where a reference field's names come from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Collection {
Set,
Material,
Section,
ReferencePoint,
Coupling,
Contact,
Tie,
Mass,
Spring,
BoundaryCondition,
Load,
FluidDomain,
Step,
/// A solved step.
SolvedStep,
/// A body of the open design.
Body,
}
impl Collection {
pub fn label(self) -> &'static str {
match self {
Collection::Set => "set",
Collection::Material => "material",
Collection::Section => "section",
Collection::ReferencePoint => "reference point",
Collection::Coupling => "coupling",
Collection::Contact => "contact",
Collection::Tie => "tie",
Collection::Mass => "point mass",
Collection::Spring => "spring",
Collection::BoundaryCondition => "boundary condition",
Collection::Load => "load",
Collection::FluidDomain => "fluid domain",
Collection::Step => "step",
Collection::SolvedStep => "solved step",
Collection::Body => "body",
}
}
}
impl Session {
/// The names a reference field may take, in order (at most [`MAX_NAMES`]).
pub fn names(&self, c: Collection) -> Vec<String> {
let m = &self.model;
let names: Box<dyn Iterator<Item = &str>> = match c {
Collection::Set => Box::new(m.sets().keys().map(String::as_str)),
Collection::Material => Box::new(m.materials().keys().map(String::as_str)),
Collection::Section => Box::new(m.sections().keys().map(String::as_str)),
Collection::ReferencePoint => Box::new(m.reference_points().keys().map(String::as_str)),
Collection::Coupling => Box::new(m.couplings().keys().map(String::as_str)),
Collection::Contact => Box::new(m.contacts().keys().map(String::as_str)),
Collection::Tie => Box::new(m.ties().keys().map(String::as_str)),
Collection::Mass => Box::new(m.masses().keys().map(String::as_str)),
Collection::Spring => Box::new(m.springs().keys().map(String::as_str)),
Collection::BoundaryCondition => Box::new(m.boundary_conditions().keys().map(String::as_str)),
Collection::Load => Box::new(m.loads().keys().map(String::as_str)),
Collection::FluidDomain => Box::new(m.fluid_domains().keys().map(String::as_str)),
Collection::Step => Box::new(m.steps().iter().map(|s| s.name.as_str())),
Collection::SolvedStep => Box::new(self.solved_steps()),
Collection::Body => Box::new(self.design.iter().flat_map(|d| d.bodies.iter().map(|b| b.name.as_str()))),
};
names.take(MAX_NAMES).map(str::to_string).collect()
}
}
/// A physical dimension: its unit follows the model's unit system.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dim {
/// No unit (a ratio, a count, a factor).
None,
Length,
Area,
Stress,
Density,
Force,
Moment,
Mass,
Inertia,
Time,
Velocity,
Acceleration,
AngularVelocity,
Stiffness,
Damping,
Viscosity,
ForcePerVolume,
Power,
HeatFlux,
FilmCoefficient,
Conductivity,
SpecificHeat,
Expansion,
Temperature,
/// Always millimetres (geometry from SolidCraft).
Millimetre,
Degree,
Hertz,
}
impl Dim {
/// Its unit in `system`, or `None` (no unit, or the system is unspecified).
pub fn unit(self, system: UnitSystem) -> Option<String> {
let fixed = match self {
Dim::Millimetre => Some("mm"),
Dim::Degree => Some("°"),
Dim::Hertz => Some("Hz"),
Dim::Expansion => Some("1/K"),
_ => None,
};
if let Some(f) = fixed {
return Some(f.into());
}
let (l, m, t, f, s) = match system {
UnitSystem::Unspecified => return None,
UnitSystem::MKgS => ("m", "kg", "s", "N", "Pa"),
UnitSystem::MmTonneS => ("mm", "t", "s", "N", "MPa"),
UnitSystem::MmKgMs => ("mm", "kg", "ms", "kN", "GPa"),
UnitSystem::MmGMs => ("mm", "g", "ms", "N", "MPa"),
UnitSystem::InLbfS => ("in", "lbf·s²/in", "s", "lbf", "psi"),
};
let inch = system == UnitSystem::InLbfS;
Some(match self {
Dim::Length => l.into(),
Dim::Area => format!("{l}²"),
Dim::Stress => s.into(),
Dim::Density if inch => "lbf·s²/in⁴".into(),
Dim::Density => format!("{m}/{l}³"),
Dim::Force => f.into(),
Dim::Moment => format!("{f}·{l}"),
Dim::Mass => m.into(),
Dim::Inertia if inch => "lbf·s²·in".into(),
Dim::Inertia => format!("{m}·{l}²"),
Dim::Time => t.into(),
Dim::Velocity => format!("{l}/{t}"),
Dim::Acceleration => format!("{l}/{t}²"),
Dim::AngularVelocity => format!("rad/{t}"),
Dim::Stiffness => format!("{f}/{l}"),
Dim::Damping => format!("{f}·{t}/{l}"),
Dim::Viscosity => format!("{s}·{t}"),
Dim::ForcePerVolume => format!("{f}/{l}³"),
Dim::Power => format!("{f}·{l}/{t}"),
Dim::HeatFlux => format!("{f}/({l}·{t})"),
Dim::FilmCoefficient => format!("{f}/({l}·{t}·K)"),
Dim::Conductivity => format!("{f}/({t}·K)"),
Dim::SpecificHeat => format!("{l}²/({t}²·K)"),
Dim::None | Dim::Temperature | Dim::Millimetre | Dim::Degree | Dim::Hertz | Dim::Expansion => return None,
})
}
}
// ---- Building fields (const, so schemas are static tables) ----
const fn field(key: &'static str, label: &'static str, kind: Kind) -> Field {
Field { key, label, kind, required: false, default: None, hint: "" }
}
impl Field {
/// Required.
pub const fn req(self) -> Field {
Field { required: true, ..self }
}
/// Starts with this JSON value.
pub const fn def(self, json: &'static str) -> Field {
Field { default: Some(json), ..self }
}
/// What an empty field means.
pub const fn hint(self, hint: &'static str) -> Field {
Field { hint, ..self }
}
}
pub const fn num(key: &'static str, label: &'static str, dim: Dim) -> Field {
field(key, label, Kind::Number { dim, min: None, max: None })
}
pub const fn range(key: &'static str, label: &'static str, dim: Dim, min: f64, max: f64) -> Field {
field(key, label, Kind::Number { dim, min: Some(min), max: Some(max) })
}
pub const fn positive(key: &'static str, label: &'static str, dim: Dim) -> Field {
field(key, label, Kind::Number { dim, min: Some(0.0), max: None })
}
pub const fn int(key: &'static str, label: &'static str, min: i64, max: i64) -> Field {
field(key, label, Kind::Integer { min, max })
}
pub const fn text(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Text)
}
pub const fn path(key: &'static str, label: &'static str, save: bool) -> Field {
field(key, label, Kind::Path { save })
}
pub const fn flag(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Bool)
}
pub const fn choice(key: &'static str, label: &'static str, options: &'static [(&'static str, &'static str)]) -> Field {
field(key, label, Kind::Choice(options))
}
pub const fn xyz(key: &'static str, label: &'static str, dim: Dim) -> Field {
field(key, label, Kind::Vector { dim, labels: &["x", "y", "z"], nullable: false })
}
/// A vector whose entries may be null (left free).
pub const fn free_vector(key: &'static str, label: &'static str, dim: Dim, labels: &'static [&'static str]) -> Field {
field(key, label, Kind::Vector { dim, labels, nullable: true })
}
pub const fn vector(key: &'static str, label: &'static str, dim: Dim, labels: &'static [&'static str]) -> Field {
field(key, label, Kind::Vector { dim, labels, nullable: false })
}
pub const fn reference(key: &'static str, label: &'static str, c: Collection) -> Field {
field(key, label, Kind::Ref(c))
}
pub const fn references(key: &'static str, label: &'static str, c: Collection) -> Field {
field(key, label, Kind::RefList(c))
}
pub const fn group(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
field(key, label, Kind::Group(fields))
}
pub const fn variants(key: &'static str, label: &'static str, variants: &'static [Variant]) -> Field {
field(key, label, Kind::Variants { tag: "type", variants })
}
pub const fn either(key: &'static str, label: &'static str, options: &'static [Field]) -> Field {
field(key, label, Kind::Either(options))
}
pub const fn unit(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Unit)
}
pub const fn rows(key: &'static str, label: &'static str, fields: &'static [Field]) -> Field {
field(key, label, Kind::Rows { fields, max: MAX_ROWS })
}
pub const fn json(key: &'static str, label: &'static str) -> Field {
field(key, label, Kind::Json)
}
pub const fn variant(tag: &'static str, label: &'static str, fields: &'static [Field]) -> Variant {
Variant { tag, label, fields }
}
/// The `name` of a new or edited definition.
pub const NAME: Field = text("name", "Name").req();
impl Kind {
/// The variant of `tag`, for [`Kind::Variants`].
pub fn variant(&self, tag: &str) -> Option<&'static Variant> {
match self {
Kind::Variants { variants, .. } => variants.iter().find(|v| v.tag == tag),
_ => None,
}
}
}
// ---- Prefilling, switching variants, checking ----
/// `params` with the defaults of the fields it leaves out filled in (required groups created,
/// the active variant's defaults too). What it already has is kept.
pub fn prefill(fields: &[Field], params: &Value) -> Value {
let mut obj = params.as_object().cloned().unwrap_or_default();
fill(fields, &mut obj, 0);
Value::Object(obj)
}
/// Nesting deeper than this is not followed (schemas are shallow; this bounds the recursion).
pub const MAX_DEPTH: usize = 8;
fn fill(fields: &[Field], obj: &mut Map<String, Value>, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for f in fields {
match f.kind {
Kind::Variants { tag, .. } if f.key.is_empty() => {
if !obj.contains_key(tag)
&& let Some(d) = parse_default(f)
{
obj.insert(tag.into(), d);
}
if let Some(v) = obj.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
fill(v.fields, obj, depth + 1);
}
}
Kind::Group(_) | Kind::Variants { .. } => {
if !obj.contains_key(f.key) {
match parse_default(f) {
Some(d) => {
obj.insert(f.key.into(), d);
}
None if f.required => {
obj.insert(f.key.into(), Value::Object(Map::new()));
}
None => continue,
}
}
if let Some(Value::Object(child)) = obj.get_mut(f.key) {
fill_nested(f, child, depth + 1);
}
}
_ => {
if !obj.contains_key(f.key)
&& let Some(d) = parse_default(f)
{
obj.insert(f.key.into(), d);
}
}
}
}
}
/// Fill a group's or a (keyed) variant field's own object.
fn fill_nested(f: &Field, child: &mut Map<String, Value>, depth: usize) {
match f.kind {
Kind::Group(fields) => fill(fields, child, depth),
Kind::Variants { tag, variants } => {
if !child.contains_key(tag)
&& let Some(first) = variants.first()
{
child.insert(tag.into(), json!(first.tag));
}
if let Some(v) = child.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
fill(v.fields, child, depth);
}
}
_ => {}
}
}
fn parse_default(f: &Field) -> Option<Value> {
f.default.and_then(|d| serde_json::from_str(d).ok())
}
/// The object for variant `tag` of variants field `f`, from `old`: the tag, the values of `old`
/// the new variant (or `common`, the fields beside the variants) also has, and the new variant's
/// defaults.
pub fn switch_variant(f: &Field, common: &[Field], old: &Map<String, Value>, tag: &str) -> Map<String, Value> {
let Kind::Variants { tag: tag_key, .. } = f.kind else { return old.clone() };
let Some(v) = f.kind.variant(tag) else { return old.clone() };
let keep = |k: &str| v.fields.iter().chain(common).any(|g| g.key == k && g.key != tag_key);
let mut out: Map<String, Value> = old.iter().filter(|(k, _)| keep(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
out.insert(tag_key.into(), json!(tag));
fill(v.fields, &mut out, 0);
out
}
/// A problem with a field's value, for the form to show beside it.
#[derive(Debug, Clone, PartialEq)]
pub struct Problem {
/// The JSON path of the field (`elastic/young`).
pub path: String,
pub message: String,
}
/// What is missing or out of range in `params`: required fields left out, numbers outside
/// their range, values of the wrong kind. An empty list does not promise the command accepts
/// them (it checks more); a problem means it would not.
pub fn problems(fields: &[Field], params: &Value) -> Vec<Problem> {
match params.as_object() {
Some(obj) => object_problems(fields, obj),
None => vec![Problem { path: String::new(), message: "the parameters must be a JSON object".into() }],
}
}
/// [`problems`] of an object's fields.
pub fn object_problems(fields: &[Field], obj: &Map<String, Value>) -> Vec<Problem> {
let mut out = Vec::new();
check(fields, obj, "", &mut out, 0);
out
}
/// The path of field `key` inside `prefix` (`elastic/young`).
pub fn join(prefix: &str, key: &str) -> String {
if prefix.is_empty() {
key.to_string()
} else if key.is_empty() {
prefix.to_string()
} else {
format!("{prefix}/{key}")
}
}
fn check(fields: &[Field], obj: &Map<String, Value>, prefix: &str, out: &mut Vec<Problem>, depth: usize) {
if depth > MAX_DEPTH || out.len() > MAX_ROWS {
return;
}
for f in fields {
if let Kind::Variants { tag, .. } = f.kind
&& f.key.is_empty()
{
match obj.get(tag).and_then(Value::as_str) {
Some(t) => match f.kind.variant(t) {
Some(v) => check(v.fields, obj, prefix, out, depth + 1),
None => out.push(Problem { path: join(prefix, tag), message: format!("`{t}` is not a {}", f.label.to_lowercase()) }),
},
None if f.required => out.push(Problem { path: join(prefix, tag), message: "required".into() }),
None => {}
}
continue;
}
let path = join(prefix, f.key);
match obj.get(f.key) {
None | Some(Value::Null) if f.required => out.push(Problem { path, message: "required".into() }),
None | Some(Value::Null) => {}
Some(v) => {
if let Some(message) = value_problem(f, v, &path, out, depth) {
out.push(Problem { path, message });
}
}
}
}
}
/// What is wrong with `v` as the value of `f`; nested problems go to `out`.
fn value_problem(f: &Field, v: &Value, path: &str, out: &mut Vec<Problem>, depth: usize) -> Option<String> {
match f.kind {
Kind::Number { min, max, .. } => match (v.as_f64().filter(|x| x.is_finite()), min, max) {
(None, ..) => Some("a number".into()),
(Some(x), Some(lo), _) if x < lo => Some(format!("at least {lo}")),
(Some(x), _, Some(hi)) if x > hi => Some(format!("at most {hi}")),
_ => None,
},
Kind::Integer { min, max } => match v.as_i64() {
Some(x) if (min..=max).contains(&x) => None,
_ => Some(format!("a whole number in {min}..={max}")),
},
Kind::Text | Kind::Path { .. } | Kind::Ref(_) => match v.as_str() {
Some(s) if f.required && s.trim().is_empty() => Some("required".into()),
Some(_) => None,
None => Some("text".into()),
},
Kind::Bool => (!v.is_boolean()).then(|| "true or false".into()),
Kind::Choice(options) => match v.as_str() {
Some(s) if options.iter().any(|(o, _)| *o == s) => None,
_ => Some(format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", "))),
},
Kind::Vector { labels, nullable, .. } => match v.as_array() {
Some(a) if a.len() == labels.len() && a.iter().all(|x| x.as_f64().is_some_and(f64::is_finite) || (nullable && x.is_null())) => None,
_ => Some(format!("{} numbers", labels.len())),
},
Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then(|| "a list of names".into()),
Kind::Group(fields) => match v.as_object() {
Some(o) => {
check(fields, o, path, out, depth + 1);
None
}
None => Some("an object".into()),
},
Kind::Variants { tag, .. } => match v.as_object() {
Some(o) => match o.get(tag).and_then(Value::as_str).and_then(|t| f.kind.variant(t)) {
Some(var) => {
check(var.fields, o, path, out, depth + 1);
None
}
None => Some(format!("needs a known `{tag}`")),
},
None => Some("an object".into()),
},
Kind::Either(options) => {
let ok = match v {
Value::String(s) => options.iter().any(|o| o.key == s && matches!(o.kind, Kind::Unit)),
Value::Object(o) if o.len() == 1 => o.iter().all(|(k, inner)| {
options.iter().find(|opt| opt.key == k).is_some_and(|opt| value_problem(opt, inner, path, out, depth + 1).is_none())
}),
_ => false,
};
(!ok).then(|| format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")))
}
Kind::Unit | Kind::Json => None,
Kind::Rows { fields, max } => match v.as_array() {
Some(a) if a.len() <= max => {
for (i, row) in a.iter().enumerate() {
match row.as_object() {
Some(o) => check(fields, o, &format!("{path}/{i}"), out, depth + 1),
None => out.push(Problem { path: format!("{path}/{i}"), message: "an object".into() }),
}
}
None
}
_ => Some(format!("a list of at most {max} rows")),
},
}
}
// ---- JSON Schema ----
/// The fields as a JSON Schema (draft 2020-12) object schema, units and hints in descriptions.
pub fn json_schema(fields: &[Field]) -> Value {
object_schema(fields, 0)
}
fn object_schema(fields: &[Field], depth: usize) -> Value {
let mut properties = Map::new();
let mut required = Vec::new();
let mut one_of = None;
for f in fields {
if let Kind::Variants { tag, variants } = f.kind
&& f.key.is_empty()
{
one_of = Some(variant_schemas(tag, variants, depth));
if f.required {
required.push(json!(tag));
}
continue;
}
properties.insert(f.key.into(), field_schema(f, depth));
if f.required {
required.push(json!(f.key));
}
}
let mut s = json!({"type": "object", "properties": properties});
if !required.is_empty() {
s["required"] = Value::Array(required);
}
if let Some(o) = one_of {
s["oneOf"] = o;
}
s
}
fn variant_schemas(tag: &str, variants: &[Variant], depth: usize) -> Value {
Value::Array(
variants
.iter()
.map(|v| {
let mut s = if depth > MAX_DEPTH { json!({"type": "object"}) } else { object_schema(v.fields, depth + 1) };
s["properties"][tag] = json!({"const": v.tag, "title": v.label});
let mut req = s.get("required").and_then(Value::as_array).cloned().unwrap_or_default();
req.push(json!(tag));
s["required"] = Value::Array(req);
s
})
.collect(),
)
}
fn field_schema(f: &Field, depth: usize) -> Value {
let mut s = match f.kind {
Kind::Number { dim, min, max } => {
let mut s = json!({"type": "number"});
if let Some(lo) = min {
s["minimum"] = json!(lo);
}
if let Some(hi) = max {
s["maximum"] = json!(hi);
}
if dim != Dim::None {
s["x-dimension"] = json!(format!("{dim:?}").to_lowercase());
}
s
}
Kind::Integer { min, max } => json!({"type": "integer", "minimum": min, "maximum": max}),
Kind::Text | Kind::Path { .. } => json!({"type": "string"}),
Kind::Bool => json!({"type": "boolean"}),
Kind::Choice(options) => json!({"enum": options.iter().map(|(o, _)| *o).collect::<Vec<_>>()}),
Kind::Vector { labels, nullable, .. } => {
let item = if nullable { json!({"type": ["number", "null"]}) } else { json!({"type": "number"}) };
json!({"type": "array", "items": item, "minItems": labels.len(), "maxItems": labels.len()})
}
Kind::Ref(c) => json!({"type": "string", "x-references": c.label()}),
Kind::RefList(c) => json!({"type": "array", "items": {"type": "string", "x-references": c.label()}}),
Kind::Group(fields) if depth <= MAX_DEPTH => object_schema(fields, depth + 1),
Kind::Variants { tag, variants } if depth <= MAX_DEPTH => json!({"oneOf": variant_schemas(tag, variants, depth + 1)}),
Kind::Either(options) if depth <= MAX_DEPTH => json!({"oneOf": options.iter().map(|o| match o.kind {
Kind::Unit => json!({"const": o.key}),
_ => json!({"type": "object", "properties": {o.key: field_schema(o, depth + 1)}, "required": [o.key], "additionalProperties": false}),
}).collect::<Vec<_>>()}),
Kind::Rows { fields, max } if depth <= MAX_DEPTH => json!({"type": "array", "items": object_schema(fields, depth + 1), "maxItems": max}),
Kind::Unit => json!({"const": f.key}),
_ => json!({}),
};
s["title"] = json!(f.label);
if !f.hint.is_empty() {
s["description"] = json!(f.hint);
}
if let Some(d) = parse_default(f) {
s["default"] = d;
}
s
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn units_follow_the_system() {
assert_eq!(Dim::Stress.unit(UnitSystem::MmTonneS).as_deref(), Some("MPa"));
assert_eq!(Dim::Density.unit(UnitSystem::MmTonneS).as_deref(), Some("t/mm³"));
assert_eq!(Dim::Moment.unit(UnitSystem::MKgS).as_deref(), Some("N·m"));
assert_eq!(Dim::Density.unit(UnitSystem::InLbfS).as_deref(), Some("lbf·s²/in⁴"));
assert_eq!(Dim::Stress.unit(UnitSystem::Unspecified), None);
assert_eq!(Dim::Millimetre.unit(UnitSystem::Unspecified).as_deref(), Some("mm"));
assert_eq!(Dim::None.unit(UnitSystem::MKgS), None);
}
const LOADISH: &[Field] = &[
NAME,
variants(
"",
"Type",
&[
variant(
"force",
"Force",
&[xyz("vector", "Vector", Dim::Force).req(), choice("distribution", "Spread", &[("total", "Total")]).def("\"total\"")],
),
variant("pressure", "Pressure", &[num("magnitude", "Magnitude", Dim::Stress).req(), reference("set", "Set", Collection::Set).req()]),
],
)
.req()
.def("\"force\""),
];
#[test]
fn prefill_switch_and_check() {
let p = prefill(LOADISH, &json!({}));
assert_eq!(p, json!({"type": "force", "distribution": "total"}));
let problems = problems(LOADISH, &p);
let paths: Vec<&str> = problems.iter().map(|p| p.path.as_str()).collect();
assert_eq!(paths, ["name", "vector"]);
let root = &LOADISH[1];
let switched = switch_variant(
root,
LOADISH,
&json!({"name": "a", "type": "force", "vector": [1, 2, 3]}).as_object().cloned().unwrap_or_default(),
"pressure",
);
assert_eq!(Value::Object(switched), json!({"name": "a", "type": "pressure"}));
assert!(super::problems(LOADISH, &json!({"name": "a", "type": "force", "vector": [1, "x", 3]})).iter().any(|p| p.path == "vector"));
assert!(super::problems(LOADISH, &json!([1])).len() == 1);
}
#[test]
fn json_schema_has_variants_and_requirements() {
let s = json_schema(LOADISH);
assert_eq!(s["required"], json!(["name", "type"]));
assert_eq!(s["oneOf"][1]["properties"]["type"]["const"], "pressure");
assert_eq!(s["oneOf"][1]["required"], json!(["magnitude", "set", "type"]));
}
}