forked from 1b-agent/fieldcraft-upstream
Problem::phrase serves both for_agents and for_form. A required name says "enter a name", a path "enter a path", anything else "required" (no more "Autosave: enter an autosave"). New kinds for what commands check (positive, range, list, name, path, invalid), and refine() to look a command's problem up in its form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
1102 lines
45 KiB
Rust
1102 lines
45 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 the field means, in a sentence or two, with a typical value: the form's tooltip and
|
||
/// the JSON Schema description. The unit is added from the field's dimension ([`Field::help_text`]).
|
||
pub help: &'static str,
|
||
/// For a [`Kind::Choice`]: a one-line note per option value (what it needs), shown under the
|
||
/// field for the option chosen.
|
||
pub notes: &'static [(&'static str, &'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,
|
||
/// A datum plane or axis.
|
||
Datum,
|
||
/// A datum plane.
|
||
DatumPlane,
|
||
Partition,
|
||
}
|
||
|
||
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",
|
||
Collection::Datum => "datum",
|
||
Collection::DatumPlane => "datum plane",
|
||
Collection::Partition => "partition",
|
||
}
|
||
}
|
||
}
|
||
|
||
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()))),
|
||
Collection::Datum => Box::new(m.datums().keys().map(String::as_str)),
|
||
Collection::DatumPlane => Box::new(m.datums().iter().filter(|(_, d)| d.plane().is_some()).map(|(n, _)| n.as_str())),
|
||
Collection::Partition => Box::new(m.partitions().iter().map(|p| p.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 {
|
||
/// The dimension's name ("stress", "length"); `None` for [`Dim::None`].
|
||
pub fn name(self) -> Option<&'static str> {
|
||
Some(match self {
|
||
Dim::None => return None,
|
||
Dim::Length | Dim::Millimetre => "length",
|
||
Dim::Area => "area",
|
||
Dim::Stress => "stress",
|
||
Dim::Density => "density",
|
||
Dim::Force => "force",
|
||
Dim::Moment => "moment",
|
||
Dim::Mass => "mass",
|
||
Dim::Inertia => "rotary inertia",
|
||
Dim::Time => "time",
|
||
Dim::Velocity => "velocity",
|
||
Dim::Acceleration => "acceleration",
|
||
Dim::AngularVelocity => "angular velocity",
|
||
Dim::Stiffness => "stiffness",
|
||
Dim::Damping => "damping",
|
||
Dim::Viscosity => "viscosity",
|
||
Dim::ForcePerVolume => "force per volume",
|
||
Dim::Power => "power",
|
||
Dim::HeatFlux => "heat flux",
|
||
Dim::FilmCoefficient => "film coefficient",
|
||
Dim::Conductivity => "conductivity",
|
||
Dim::SpecificHeat => "specific heat",
|
||
Dim::Expansion => "expansion",
|
||
Dim::Temperature => "temperature",
|
||
Dim::Degree => "angle",
|
||
Dim::Hertz => "frequency",
|
||
})
|
||
}
|
||
|
||
/// Whether its unit follows the model's unit system (a fixed unit such as degrees does not).
|
||
pub fn follows_system(self) -> bool {
|
||
self.name().is_some() && self.unit(UnitSystem::Unspecified).is_none()
|
||
}
|
||
|
||
/// 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: "", help: "", notes: &[] }
|
||
}
|
||
|
||
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 }
|
||
}
|
||
/// What it means ([`Field::help`]).
|
||
pub const fn help(self, help: &'static str) -> Field {
|
||
Field { help, ..self }
|
||
}
|
||
|
||
/// A one-line note per option value ([`Field::notes`]).
|
||
pub const fn notes(self, notes: &'static [(&'static str, &'static str)]) -> Field {
|
||
Field { notes, ..self }
|
||
}
|
||
/// The note for this option value (the default when `None`).
|
||
pub fn note(&self, value: Option<&str>) -> Option<&'static str> {
|
||
let value = value.or_else(|| self.default.map(|d| d.trim_matches('"')))?;
|
||
self.notes.iter().find(|(v, _)| *v == value).map(|(_, n)| *n)
|
||
}
|
||
|
||
/// Its physical dimension, for a number or a vector of numbers.
|
||
pub fn dim(&self) -> Option<Dim> {
|
||
match self.kind {
|
||
Kind::Number { dim, .. } | Kind::Vector { dim, .. } => Some(dim),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// What its value is measured in, as a form shows it: the unit in `system` ("MPa"), or, with
|
||
/// no unit system, the dimension in brackets ("[stress]"). `None` for a number without a unit.
|
||
pub fn unit_label(&self, system: UnitSystem) -> Option<String> {
|
||
let dim = self.dim()?;
|
||
dim.unit(system).or_else(|| dim.name().map(|n| format!("[{n}]")))
|
||
}
|
||
|
||
/// The help as the form's tooltip shows it: [`Field::help`] and the unit (or, with no unit system, the dimension and where to set the system).
|
||
pub fn help_text(&self, system: UnitSystem) -> String {
|
||
let mut out = self.help.to_string();
|
||
let mut line = |s: String| {
|
||
if !out.is_empty() {
|
||
out.push('\n');
|
||
}
|
||
out.push_str(&s);
|
||
};
|
||
if let Some(dim) = self.dim() {
|
||
match (dim.unit(system), dim.name()) {
|
||
(Some(u), _) => line(format!("Unit: {u}.")),
|
||
(None, Some(n)) => {
|
||
let hint = if system == UnitSystem::Unspecified { format!(" {UNITS_HINT}") } else { String::new() };
|
||
line(format!("Unit: a {n} in the model's units.{hint}"))
|
||
}
|
||
(None, None) => {}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// What a form says when the model has no unit system.
|
||
pub const UNITS_HINT: &str = "Set a unit system: Properties › Units.";
|
||
|
||
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().help("A name for it, unique among its kind; other definitions refer to it by this name.");
|
||
|
||
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: the field, where it is, and what is wrong. [`Problem::for_form`]
|
||
/// phrases it by the field's label (the form), [`Problem::for_agents`] by its JSON path (agents,
|
||
/// the engine's messages).
|
||
#[derive(Debug, Clone)]
|
||
pub struct Problem {
|
||
/// The JSON path of the field (`elastic/young`, `rows/0/name`); empty for the parameters as a
|
||
/// whole.
|
||
pub path: String,
|
||
/// The field it is about; `None` for the parameters as a whole. For a variant's tag, the
|
||
/// [`Kind::Variants`] field.
|
||
pub field: Option<&'static Field>,
|
||
pub kind: ProblemKind,
|
||
}
|
||
|
||
/// What is wrong with a value.
|
||
#[derive(Debug, Clone, PartialEq)]
|
||
pub enum ProblemKind {
|
||
/// Left out (or empty text).
|
||
Required,
|
||
/// Not a finite number.
|
||
NotANumber,
|
||
/// A number below the field's minimum.
|
||
Below(f64),
|
||
/// A number above the field's maximum.
|
||
Above(f64),
|
||
/// Not a whole number in `min..=max`.
|
||
NotAWholeNumber {
|
||
min: i64,
|
||
max: i64,
|
||
},
|
||
NotText,
|
||
NotABool,
|
||
/// Not one of the field's choices (or, for [`Kind::Either`], of its options).
|
||
NotAChoice,
|
||
/// Not a list of `len` numbers.
|
||
NotAVector {
|
||
len: usize,
|
||
},
|
||
NotNames,
|
||
NotAnObject,
|
||
/// A variant tag the field does not have (`None`: no tag at all).
|
||
UnknownVariant(Option<String>),
|
||
/// More rows than `max`, or not a list.
|
||
TooManyRows {
|
||
max: usize,
|
||
},
|
||
/// Not a number greater than 0.
|
||
NotPositive,
|
||
/// Not a number in `min..=max`.
|
||
NotInRange {
|
||
min: f64,
|
||
max: f64,
|
||
},
|
||
/// Not a list of at most `max` of `what` ("[x, y, z]", "labels").
|
||
NotAList {
|
||
max: usize,
|
||
what: &'static str,
|
||
},
|
||
NotAName,
|
||
NotAPath,
|
||
/// Wrong for a reason the schema does not describe: what it must be or do instead
|
||
/// ("names the region of 2D elements; a solid block fills its body").
|
||
Invalid(String),
|
||
}
|
||
|
||
impl PartialEq for Problem {
|
||
/// Problems are equal when they are about the same path and are the same problem (the field
|
||
/// is where the path leads).
|
||
fn eq(&self, other: &Self) -> bool {
|
||
self.path == other.path && self.kind == other.kind
|
||
}
|
||
}
|
||
|
||
impl Problem {
|
||
fn new(path: String, field: &'static Field, kind: ProblemKind) -> Self {
|
||
Problem { path, field: Some(field), kind }
|
||
}
|
||
|
||
/// A problem with the value at `path`, its field not looked up yet ([`Problem::in_form`]).
|
||
pub fn at(path: impl Into<String>, kind: ProblemKind) -> Self {
|
||
Problem { path: path.into(), field: None, kind }
|
||
}
|
||
|
||
/// The problem with its field looked up in `fields` (when it has none yet and the path is
|
||
/// there).
|
||
pub fn in_form(mut self, fields: &'static [Field]) -> Self {
|
||
if self.field.is_none() && !self.path.is_empty() {
|
||
self.field = field_at(fields, &self.path);
|
||
}
|
||
self
|
||
}
|
||
|
||
/// What the value must be, without the field's name ("required", "at least 0").
|
||
pub fn expected(&self) -> String {
|
||
let f = self.field;
|
||
match &self.kind {
|
||
ProblemKind::Required => "required".into(),
|
||
ProblemKind::NotANumber => "a number".into(),
|
||
ProblemKind::Below(lo) => format!("at least {lo}"),
|
||
ProblemKind::Above(hi) => format!("at most {hi}"),
|
||
ProblemKind::NotAWholeNumber { min, max } => format!("a whole number in {min}..={max}"),
|
||
ProblemKind::NotText => "text".into(),
|
||
ProblemKind::NotABool => "true or false".into(),
|
||
ProblemKind::NotAChoice => match f.map(|f| f.kind) {
|
||
Some(Kind::Choice(options)) => format!("one of {}", options.iter().map(|(o, _)| *o).collect::<Vec<_>>().join(", ")),
|
||
Some(Kind::Either(options)) => format!("one of {}", options.iter().map(|o| o.key).collect::<Vec<_>>().join(", ")),
|
||
_ => "one of the choices".into(),
|
||
},
|
||
ProblemKind::NotAVector { len } => format!("{len} numbers"),
|
||
ProblemKind::NotNames => "a list of names".into(),
|
||
ProblemKind::NotAnObject if f.is_none() => "a JSON object".into(),
|
||
ProblemKind::NotAnObject => "an object".into(),
|
||
ProblemKind::UnknownVariant(Some(t)) => format!("a known {} (not `{t}`)", f.map_or("type".into(), |f| f.label.to_lowercase())),
|
||
ProblemKind::UnknownVariant(None) => match f.map(|f| f.kind) {
|
||
Some(Kind::Variants { tag, .. }) => format!("an object with a known `{tag}`"),
|
||
_ => "an object with a known type".into(),
|
||
},
|
||
ProblemKind::TooManyRows { max } => format!("a list of at most {max} rows"),
|
||
ProblemKind::NotPositive => "a positive number".into(),
|
||
ProblemKind::NotInRange { min, max } => format!("a number in {min}..={max}"),
|
||
ProblemKind::NotAList { max, what } => format!("a list of at most {max} {what}"),
|
||
ProblemKind::NotAName => "a name".into(),
|
||
ProblemKind::NotAPath => "a file path".into(),
|
||
ProblemKind::Invalid(why) => why.clone(),
|
||
}
|
||
}
|
||
|
||
/// The problem as agents read it, naming the field by its JSON path:
|
||
/// "`elastic/young` is required", "`bands` must be a whole number in 2..=24".
|
||
pub fn for_agents(&self) -> String {
|
||
self.phrase(Naming::Key)
|
||
}
|
||
|
||
/// The problem as a form shows it, naming the field by its label and saying what to do:
|
||
/// "Material: choose a material", "Young's modulus: must be at least 0".
|
||
pub fn for_form(&self) -> String {
|
||
self.phrase(Naming::Label)
|
||
}
|
||
|
||
/// The one phrasing of a problem, naming the field by its key or by its label.
|
||
pub fn phrase(&self, naming: Naming) -> String {
|
||
let by_label = naming == Naming::Label;
|
||
let field = self.field.filter(|_| by_label);
|
||
let subject = match field {
|
||
Some(f) => format!("{}:", f.label),
|
||
None if self.path.is_empty() => if by_label { "The parameters" } else { "the parameters" }.to_string(),
|
||
None => format!("`{}`", self.path),
|
||
};
|
||
let required = self.kind == ProblemKind::Required;
|
||
let what = match (field.map(|f| f.kind), &self.kind) {
|
||
(_, ProblemKind::Invalid(why)) => why.clone(),
|
||
(None, _) if required => "is required".into(),
|
||
(None, _) => format!("must be {}", self.expected()),
|
||
(Some(Kind::Ref(c)), _) if required => format!("choose {}", article(c.label())),
|
||
(Some(Kind::RefList(c)), _) if required => format!("choose at least one {}", c.label()),
|
||
(Some(Kind::Either(options)), _) => {
|
||
format!("choose {}", options.iter().map(|o| article(&lower(o.label))).collect::<Vec<_>>().join(" or "))
|
||
}
|
||
(Some(Kind::Choice(options)), _) => format!("choose one of {}", options.iter().map(|(_, l)| *l).collect::<Vec<_>>().join(", ")),
|
||
(Some(Kind::Variants { .. }), _) => format!("choose {}", article(&lower(field.map_or("", |f| f.label)))),
|
||
(Some(Kind::Path { .. }), _) if required => "enter a path".into(),
|
||
(Some(Kind::Text), _) if required && field.is_some_and(|f| f.key == "name") => "enter a name".into(),
|
||
(Some(_), _) if required => "required".into(),
|
||
(Some(_), _) => format!("must be {}", self.expected()),
|
||
};
|
||
format!("{subject} {what}")
|
||
}
|
||
}
|
||
|
||
/// How a problem names its field: by its JSON path (agents) or by its label (a form).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum Naming {
|
||
Key,
|
||
Label,
|
||
}
|
||
|
||
/// A command's problem with `params`, made as precise as its form `fields` can: when the
|
||
/// command names a group (`plastic`) and the form finds a problem with a field inside it
|
||
/// (`plastic/yield_stress` left out), that problem; otherwise the command's own, with its field
|
||
/// looked up.
|
||
pub fn refine(fields: &'static [Field], params: &Value, problem: Problem) -> Problem {
|
||
let inside = |p: &Problem| p.path.strip_prefix(problem.path.as_str()).is_some_and(|r| r.starts_with('/'));
|
||
if !problem.path.is_empty()
|
||
&& let Some(precise) = problems(fields, params).into_iter().find(inside)
|
||
{
|
||
return precise;
|
||
}
|
||
problem.in_form(fields)
|
||
}
|
||
|
||
/// "a material", "an element".
|
||
fn article(noun: &str) -> String {
|
||
let an = noun.starts_with(['a', 'e', 'i', 'o', 'u']);
|
||
format!("{} {noun}", if an { "an" } else { "a" })
|
||
}
|
||
|
||
/// A label lower-cased, but not its acronyms ("Reference point" → "reference point", "RBE2" stays).
|
||
fn lower(label: &str) -> String {
|
||
let mut chars = label.chars();
|
||
match (chars.next(), chars.next()) {
|
||
(Some(c), Some(d)) if d.is_lowercase() => c.to_lowercase().chain(label.chars().skip(1)).collect(),
|
||
_ => label.to_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: &'static [Field], params: &Value) -> Vec<Problem> {
|
||
match params.as_object() {
|
||
Some(obj) => object_problems(fields, obj),
|
||
None => vec![Problem { path: String::new(), field: None, kind: ProblemKind::NotAnObject }],
|
||
}
|
||
}
|
||
|
||
/// [`problems`] of an object's fields.
|
||
pub fn object_problems(fields: &'static [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}")
|
||
}
|
||
}
|
||
|
||
/// The field at `path` (`elastic/young`, `rows/0/name`), for a key the engine names. A variant's
|
||
/// tag gives its [`Kind::Variants`] field; an [`Kind::Either`] option's key gives the option.
|
||
pub fn field_at(fields: &'static [Field], path: &str) -> Option<&'static Field> {
|
||
let mut segments = path.split('/');
|
||
let mut field = find(fields, segments.next()?)?;
|
||
for (depth, segment) in segments.enumerate() {
|
||
if depth > MAX_DEPTH {
|
||
return None;
|
||
}
|
||
field = match field.kind {
|
||
Kind::Rows { .. } if segment.parse::<usize>().is_ok() => field,
|
||
Kind::Group(inner) | Kind::Rows { fields: inner, .. } => find(inner, segment)?,
|
||
Kind::Variants { tag, .. } if tag == segment => field,
|
||
Kind::Variants { variants, .. } => variants.iter().find_map(|v| find(v.fields, segment))?,
|
||
Kind::Either(options) => options.iter().find(|o| o.key == segment)?,
|
||
_ => return None,
|
||
};
|
||
}
|
||
Some(field)
|
||
}
|
||
|
||
/// The field of `fields` with key `key`, looking into flattened variants.
|
||
fn find(fields: &'static [Field], key: &str) -> Option<&'static Field> {
|
||
fields.iter().find_map(|f| match f.kind {
|
||
Kind::Variants { tag, variants } if f.key.is_empty() => {
|
||
if tag == key {
|
||
Some(f)
|
||
} else {
|
||
variants.iter().find_map(|v| find(v.fields, key))
|
||
}
|
||
}
|
||
_ => (f.key == key).then_some(f),
|
||
})
|
||
}
|
||
|
||
fn check(fields: &'static [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::new(join(prefix, tag), f, ProblemKind::UnknownVariant(Some(t.to_string())))),
|
||
},
|
||
None if f.required => out.push(Problem::new(join(prefix, tag), f, ProblemKind::Required)),
|
||
None => {}
|
||
}
|
||
continue;
|
||
}
|
||
let path = join(prefix, f.key);
|
||
match obj.get(f.key) {
|
||
None | Some(Value::Null) if f.required => out.push(Problem::new(path, f, ProblemKind::Required)),
|
||
None | Some(Value::Null) => {}
|
||
Some(v) => {
|
||
if let Some(kind) = value_problem(f, v, &path, out, depth) {
|
||
out.push(Problem::new(path, f, kind));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What is wrong with `v` as the value of `f`; nested problems go to `out`.
|
||
fn value_problem(f: &'static Field, v: &Value, path: &str, out: &mut Vec<Problem>, depth: usize) -> Option<ProblemKind> {
|
||
match f.kind {
|
||
Kind::Number { min, max, .. } => match (v.as_f64().filter(|x| x.is_finite()), min, max) {
|
||
(None, ..) => Some(ProblemKind::NotANumber),
|
||
(Some(x), Some(lo), _) if x < lo => Some(ProblemKind::Below(lo)),
|
||
(Some(x), _, Some(hi)) if x > hi => Some(ProblemKind::Above(hi)),
|
||
_ => None,
|
||
},
|
||
Kind::Integer { min, max } => match v.as_i64() {
|
||
Some(x) if (min..=max).contains(&x) => None,
|
||
_ => Some(ProblemKind::NotAWholeNumber { min, max }),
|
||
},
|
||
Kind::Text | Kind::Path { .. } | Kind::Ref(_) => match v.as_str() {
|
||
Some(s) if f.required && s.trim().is_empty() => Some(ProblemKind::Required),
|
||
Some(_) => None,
|
||
None => Some(ProblemKind::NotText),
|
||
},
|
||
Kind::Bool => (!v.is_boolean()).then_some(ProblemKind::NotABool),
|
||
Kind::Choice(options) => match v.as_str() {
|
||
Some(s) if options.iter().any(|(o, _)| *o == s) => None,
|
||
_ => Some(ProblemKind::NotAChoice),
|
||
},
|
||
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(ProblemKind::NotAVector { len: labels.len() }),
|
||
},
|
||
Kind::RefList(_) => (!v.as_array().is_some_and(|a| a.iter().all(Value::is_string))).then_some(ProblemKind::NotNames),
|
||
Kind::Group(fields) => match v.as_object() {
|
||
Some(o) => {
|
||
check(fields, o, path, out, depth + 1);
|
||
None
|
||
}
|
||
None => Some(ProblemKind::NotAnObject),
|
||
},
|
||
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(ProblemKind::UnknownVariant(None)),
|
||
},
|
||
None => Some(ProblemKind::NotAnObject),
|
||
},
|
||
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_some(ProblemKind::NotAChoice)
|
||
}
|
||
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() {
|
||
let row_path = format!("{path}/{i}");
|
||
match row.as_object() {
|
||
Some(o) => check(fields, o, &row_path, out, depth + 1),
|
||
None => out.push(Problem::new(row_path, f, ProblemKind::NotAnObject)),
|
||
}
|
||
}
|
||
None
|
||
}
|
||
_ => Some(ProblemKind::TooManyRows { max }),
|
||
},
|
||
}
|
||
}
|
||
|
||
// ---- 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);
|
||
let description = match (f.help, f.hint) {
|
||
("", "") => None,
|
||
(help, "") => Some(help.to_string()),
|
||
("", hint) => Some(hint.to_string()),
|
||
(help, hint) => Some(format!("{help} Empty or default: {hint}.")),
|
||
};
|
||
if let Some(d) = description {
|
||
s["description"] = json!(d);
|
||
}
|
||
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);
|
||
}
|
||
|
||
/// One problem, two phrasings: by label for the form, by path for agents.
|
||
#[test]
|
||
fn problems_carry_their_field_and_kind() {
|
||
let p = problems(LOADISH, &json!({"type": "pressure", "magnitude": "x"}));
|
||
let kinds: Vec<(&str, &ProblemKind)> = p.iter().map(|p| (p.path.as_str(), &p.kind)).collect();
|
||
assert_eq!(kinds, [("name", &ProblemKind::Required), ("magnitude", &ProblemKind::NotANumber), ("set", &ProblemKind::Required)]);
|
||
assert_eq!(p.iter().map(Problem::for_form).collect::<Vec<_>>(), ["Name: enter a name", "Magnitude: must be a number", "Set: choose a set"]);
|
||
assert_eq!(
|
||
p.iter().map(Problem::for_agents).collect::<Vec<_>>(),
|
||
["`name` is required", "`magnitude` must be a number", "`set` is required"]
|
||
);
|
||
let unknown = problems(LOADISH, &json!({"name": "a", "type": "spin"}));
|
||
assert_eq!(unknown.first().map(Problem::for_form).as_deref(), Some("Type: choose a type"));
|
||
assert_eq!(unknown.first().map(Problem::for_agents).as_deref(), Some("`type` must be a known type (not `spin`)"));
|
||
assert_eq!(problems(LOADISH, &json!([1])).first().map(Problem::for_agents).as_deref(), Some("the parameters must be a JSON object"));
|
||
assert!(field_at(LOADISH, "magnitude").is_some_and(|f| f.label == "Magnitude"));
|
||
}
|
||
|
||
#[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"]));
|
||
}
|
||
}
|