685 lines
31 KiB
Rust
685 lines
31 KiB
Rust
//! Bounded typed configuration rules. The saved row remains the editable source;
|
|
//! ordered rules derive an effective design without overwriting its cells.
|
|
use super::Column;
|
|
use crate::{DocError, Document, Result, expr};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
|
|
pub const MAX_RULES: usize = 128;
|
|
const MAX_NODES: usize = 64;
|
|
const MAX_DEPTH: usize = 8;
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ConfigRule {
|
|
pub id: String,
|
|
pub name: String,
|
|
#[serde(default = "enabled")]
|
|
pub enabled: bool,
|
|
/// Unit context for feature-length expressions, fixed when authoring a rule.
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub length_unit: Option<String>,
|
|
pub condition: RuleCondition,
|
|
pub effect: RuleEffect,
|
|
}
|
|
fn enabled() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum RuleCondition {
|
|
Always,
|
|
All { conditions: Vec<RuleCondition> },
|
|
Any { conditions: Vec<RuleCondition> },
|
|
Not { condition: Box<RuleCondition> },
|
|
Parameter { parameter: String, operator: RuleOperator, expression: String },
|
|
Suppression { feature: u64, value: bool },
|
|
}
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum RuleOperator {
|
|
Eq,
|
|
Ne,
|
|
Lt,
|
|
Le,
|
|
Gt,
|
|
Ge,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
|
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
|
pub enum RuleEffect {
|
|
DriveParameter { parameter: String, expression: String },
|
|
DriveSuppression { feature: u64, value: bool },
|
|
ParameterRange { parameter: String, minimum: String, maximum: String },
|
|
AllowParameterValues { parameter: String, expressions: Vec<String> },
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize)]
|
|
pub struct RuleTrace {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub matched: bool,
|
|
pub effect: RuleEffect,
|
|
}
|
|
|
|
/// Check saved rule structure before recursive serde decoding. Absence is backward
|
|
/// compatible; malformed present data is refused. Runtime evaluation checks it again.
|
|
pub fn validate_rules_json(document: &Value) -> Result<()> {
|
|
let Some(rules) = document.get("configs").and_then(|configs| configs.get("rules")) else { return Ok(()) };
|
|
let rules = rules.as_array().ok_or_else(|| invalid("rules must be an array"))?;
|
|
if rules.len() > MAX_RULES {
|
|
return Err(invalid("too many rules"));
|
|
}
|
|
for rule in rules {
|
|
let rule = rule.as_object().ok_or_else(|| invalid("rule must be an object"))?;
|
|
for (key, limit) in [("id", 64), ("name", 128)] {
|
|
let value = rule.get(key).and_then(Value::as_str).ok_or_else(|| invalid("rule id and name are required"))?;
|
|
if value.trim().is_empty() || value.chars().count() > limit {
|
|
return Err(invalid("rule id or name is empty or too long"));
|
|
}
|
|
}
|
|
if let Some(unit) = rule.get("length_unit") {
|
|
let unit =
|
|
unit.as_str().filter(|unit| matches!(*unit, "mm" | "cm" | "m" | "in" | "ft")).ok_or_else(|| invalid("invalid rule length unit"))?;
|
|
let _ = unit;
|
|
}
|
|
let mut pending = vec![(rule.get("condition").ok_or_else(|| invalid("condition required"))?, 1usize)];
|
|
let mut count = 0;
|
|
while let Some((condition, depth)) = pending.pop() {
|
|
count += 1;
|
|
if depth > MAX_DEPTH || count > MAX_NODES {
|
|
return Err(invalid("condition nesting or node limit exceeded"));
|
|
}
|
|
let kind = condition.get("type").and_then(Value::as_str).ok_or_else(|| invalid("condition type required"))?;
|
|
match kind {
|
|
"all" | "any" => {
|
|
let children = condition.get("conditions").and_then(Value::as_array).ok_or_else(|| invalid("conditions must be an array"))?;
|
|
if children.is_empty() || children.len() > MAX_NODES {
|
|
return Err(invalid("condition group is empty or too large"));
|
|
}
|
|
pending.extend(children.iter().map(|child| (child, depth + 1)));
|
|
}
|
|
"not" => pending.push((condition.get("condition").ok_or_else(|| invalid("nested condition required"))?, depth + 1)),
|
|
"parameter" => {
|
|
check_parameter(condition.get("parameter"))?;
|
|
check_expression(condition.get("expression"))?;
|
|
}
|
|
"always" | "suppression" => {}
|
|
_ => return Err(invalid("unknown condition type")),
|
|
}
|
|
}
|
|
let effect = rule.get("effect").ok_or_else(|| invalid("effect required"))?;
|
|
if effect.get("type").and_then(Value::as_str) != Some("drive_suppression") {
|
|
check_parameter(effect.get("parameter"))?;
|
|
}
|
|
match effect.get("type").and_then(Value::as_str) {
|
|
Some("drive_parameter") => check_expression(effect.get("expression"))?,
|
|
Some("parameter_range") => {
|
|
check_expression(effect.get("minimum"))?;
|
|
check_expression(effect.get("maximum"))?;
|
|
}
|
|
Some("allow_parameter_values") => {
|
|
let expressions = effect.get("expressions").and_then(Value::as_array).ok_or_else(|| invalid("allowed values must be an array"))?;
|
|
if expressions.is_empty() || expressions.len() > 128 {
|
|
return Err(invalid("allowed values are empty or too many"));
|
|
}
|
|
for expression in expressions {
|
|
check_expression(Some(expression))?;
|
|
}
|
|
}
|
|
Some("drive_suppression") => {}
|
|
_ => return Err(invalid("unknown effect type")),
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn check_expression(value: Option<&Value>) -> Result<()> {
|
|
let text = value.and_then(Value::as_str).ok_or_else(|| invalid("expression must be text"))?;
|
|
if text.trim().is_empty() || text.len() > 4096 {
|
|
return Err(invalid("expression is empty or too long"));
|
|
}
|
|
Ok(())
|
|
}
|
|
fn check_parameter(value: Option<&Value>) -> Result<()> {
|
|
let name = value.and_then(Value::as_str).ok_or_else(|| invalid("parameter name required"))?;
|
|
if name.is_empty() || name.len() > 64 || Document::param_name_problem(name).is_some() {
|
|
return Err(invalid("invalid parameter name"));
|
|
}
|
|
Ok(())
|
|
}
|
|
fn invalid(message: &str) -> DocError {
|
|
DocError::Invalid(format!("configuration rules: {message}"))
|
|
}
|
|
|
|
fn operands(
|
|
doc: &Document,
|
|
values: &BTreeMap<String, expr::Value>,
|
|
parameter: &str,
|
|
expression: &str,
|
|
length_unit: Option<&str>,
|
|
) -> Result<(f64, f64)> {
|
|
let lhs = values.get(parameter).copied().ok_or_else(|| invalid(&format!("parameter `{parameter}` is missing or invalid")))?;
|
|
let unit = doc
|
|
.param(parameter)
|
|
.map(|p| p.unit.as_str())
|
|
.or_else(|| lhs.kind().map(|kind| if kind == expr::Kind::Length { length_unit.unwrap_or(doc.units.as_str()) } else { kind.default_unit() }))
|
|
.unwrap_or("");
|
|
let defaults = expr::Defaults::for_unit(unit, expr::unit_info(&doc.units).map(|(_, scale)| scale).unwrap_or(1.0));
|
|
let rhs = expr::eval_with_defaults(
|
|
expression,
|
|
&|name| values.get(name).copied().ok_or_else(|| invalid(&format!("unknown parameter `{name}`"))),
|
|
defaults,
|
|
)?;
|
|
let kind = lhs.kind().ok_or_else(|| invalid("comparison requires length, angle or unitless values"))?;
|
|
let right = rhs.to_kind_in(kind, defaults)?;
|
|
if !lhs.v.is_finite() || !right.is_finite() {
|
|
return Err(invalid("comparison requires finite values"));
|
|
}
|
|
Ok((lhs.v, right))
|
|
}
|
|
fn close(a: f64, b: f64) -> bool {
|
|
(a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0)
|
|
}
|
|
fn validate_drive_graph(graph: &BTreeMap<String, Vec<String>>, parameter: &str, expression: &str) -> Result<()> {
|
|
let mut pending = expr::references(expression);
|
|
let mut visited = BTreeSet::new();
|
|
while let Some(name) = pending.pop() {
|
|
if name == parameter {
|
|
return Err(invalid(&format!("drive for `{parameter}` creates a parameter cycle")));
|
|
}
|
|
if visited.insert(name.clone())
|
|
&& let Some(references) = graph.get(&name)
|
|
{
|
|
pending.extend(references.iter().cloned());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
fn condition(doc: &Document, values: &BTreeMap<String, expr::Value>, value: &RuleCondition, length_unit: Option<&str>) -> Result<bool> {
|
|
match value {
|
|
RuleCondition::Always => Ok(true),
|
|
RuleCondition::All { conditions } => {
|
|
for c in conditions {
|
|
if !condition(doc, values, c, length_unit)? {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
RuleCondition::Any { conditions } => {
|
|
for c in conditions {
|
|
if condition(doc, values, c, length_unit)? {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
RuleCondition::Not { condition: c } => Ok(!condition(doc, values, c, length_unit)?),
|
|
RuleCondition::Suppression { feature, value } => {
|
|
Ok(doc.feature(*feature).ok_or_else(|| invalid("condition feature is gone"))?.suppressed == *value)
|
|
}
|
|
RuleCondition::Parameter { parameter, operator, expression } => {
|
|
let (a, b) = operands(doc, values, parameter, expression, length_unit)?;
|
|
Ok(match operator {
|
|
RuleOperator::Eq => close(a, b),
|
|
RuleOperator::Ne => !close(a, b),
|
|
RuleOperator::Lt => a < b,
|
|
RuleOperator::Le => a <= b || close(a, b),
|
|
RuleOperator::Gt => a > b,
|
|
RuleOperator::Ge => a >= b || close(a, b),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Document {
|
|
/// Apply ordered drives, then enforce all matched restrictions on the final state.
|
|
/// Running on a scratch document makes a failing rule fully atomic for callers.
|
|
pub(super) fn evaluate_configuration_rules(&mut self) -> Result<Vec<RuleTrace>> {
|
|
validate_rules_json(&serde_json::json!({"configs":{"rules":self.configs.rules}}))?;
|
|
let mut ids = BTreeSet::new();
|
|
let mut trace = Vec::new();
|
|
let mut restrictions = Vec::new();
|
|
let (mut values, _) = self.param_values();
|
|
let parameter_names: BTreeSet<String> = self.all_param_exprs().into_iter().map(|(name, _, _)| name).collect();
|
|
let mut dependency_graph: BTreeMap<String, Vec<String>> =
|
|
self.all_param_exprs().into_iter().map(|(name, expression, _)| (name, expr::references(&expression))).collect();
|
|
for rule in self.configs.rules.clone() {
|
|
if !ids.insert(rule.id.clone()) {
|
|
return Err(invalid("duplicate rule id"));
|
|
}
|
|
if let Some(name) = rule.parameter_references().iter().find(|name| !parameter_names.contains(*name)) {
|
|
return Err(invalid(&format!("rule `{}` references missing parameter `{name}`", rule.name)));
|
|
}
|
|
// Targets must remain configured, including disabled rules, so dormant rules
|
|
// cannot silently become invalid and surprise a subsequent activation.
|
|
let target = match &rule.effect {
|
|
RuleEffect::DriveParameter { parameter, .. }
|
|
| RuleEffect::ParameterRange { parameter, .. }
|
|
| RuleEffect::AllowParameterValues { parameter, .. } => Column::Param(parameter.clone()),
|
|
RuleEffect::DriveSuppression { feature, .. } => Column::Suppress(*feature),
|
|
};
|
|
if !self.configs.columns.contains(&target) {
|
|
return Err(invalid(&format!("rule `{}` target is not configured", rule.name)));
|
|
}
|
|
let matched = rule.enabled
|
|
&& condition(self, &values, &rule.condition, rule.length_unit.as_deref())
|
|
.map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
|
|
trace.push(RuleTrace { id: rule.id, name: rule.name.clone(), matched, effect: rule.effect.clone() });
|
|
if !matched {
|
|
continue;
|
|
}
|
|
match rule.effect {
|
|
RuleEffect::DriveParameter { parameter, mut expression } => {
|
|
validate_drive_graph(&dependency_graph, ¶meter, &expression)?;
|
|
dependency_graph.insert(parameter.clone(), expr::references(&expression));
|
|
if self.param(¶meter).is_none()
|
|
&& rule.length_unit.as_deref().is_some_and(|unit| unit != self.units)
|
|
&& values.get(¶meter).is_some_and(|value| value.kind() == Some(expr::Kind::Length))
|
|
{
|
|
let (_, value) = operands(self, &values, ¶meter, &expression, rule.length_unit.as_deref())?;
|
|
expression = format!("{value} mm");
|
|
}
|
|
self.change_params_atomic(&[(parameter, expression)]).map_err(|e| invalid(&format!("rule `{}`: {e}", rule.name)))?;
|
|
values = self.param_values().0;
|
|
}
|
|
RuleEffect::DriveSuppression { feature, value } => {
|
|
self.feature_mut(feature).ok_or_else(|| invalid("rule feature is gone"))?.suppressed = value
|
|
}
|
|
effect => restrictions.push((rule.name, effect, rule.length_unit)),
|
|
}
|
|
}
|
|
for (name, effect, length_unit) in restrictions {
|
|
let valid = match effect {
|
|
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
|
let (value, min) = operands(self, &values, ¶meter, &minimum, length_unit.as_deref())?;
|
|
let (_, max) = operands(self, &values, ¶meter, &maximum, length_unit.as_deref())?;
|
|
min <= max && (value >= min || close(value, min)) && (value <= max || close(value, max))
|
|
}
|
|
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
|
let mut valid = false;
|
|
for expression in expressions {
|
|
let (a, b) = operands(self, &values, ¶meter, &expression, length_unit.as_deref())?;
|
|
valid |= close(a, b);
|
|
}
|
|
valid
|
|
}
|
|
_ => true,
|
|
};
|
|
if !valid {
|
|
return Err(invalid(&format!("rule `{name}` restricts the final value")));
|
|
}
|
|
}
|
|
Ok(trace)
|
|
}
|
|
}
|
|
|
|
impl ConfigRule {
|
|
pub(super) fn preserve_length_units(&mut self, parameters: &BTreeSet<String>, unit: &str) {
|
|
if self.length_unit.is_none() {
|
|
self.length_unit = Some(unit.to_string());
|
|
}
|
|
fn annotate(parameter: &str, expression: &mut String, parameters: &BTreeSet<String>, unit: &str) {
|
|
if parameters.contains(parameter) && expression.trim().parse::<f64>().is_ok() {
|
|
*expression = format!("{} {unit}", expression.trim());
|
|
}
|
|
}
|
|
fn walk(condition: &mut RuleCondition, parameters: &BTreeSet<String>, unit: &str) {
|
|
match condition {
|
|
RuleCondition::Parameter { parameter, expression, .. } => annotate(parameter, expression, parameters, unit),
|
|
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
|
for condition in conditions {
|
|
walk(condition, parameters, unit);
|
|
}
|
|
}
|
|
RuleCondition::Not { condition } => walk(condition, parameters, unit),
|
|
_ => {}
|
|
}
|
|
}
|
|
walk(&mut self.condition, parameters, unit);
|
|
match &mut self.effect {
|
|
RuleEffect::DriveParameter { parameter, expression } => annotate(parameter, expression, parameters, unit),
|
|
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
|
annotate(parameter, minimum, parameters, unit);
|
|
annotate(parameter, maximum, parameters, unit);
|
|
}
|
|
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
|
for expression in expressions {
|
|
annotate(parameter, expression, parameters, unit);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Parameter references include targets and condition/effect expressions.
|
|
pub fn parameter_references(&self) -> Vec<String> {
|
|
let mut copy = self.clone();
|
|
let mut references = Vec::new();
|
|
copy.visit_parameters(&mut |text, expression| {
|
|
if expression {
|
|
references.extend(expr::references(text));
|
|
} else {
|
|
references.push(text.clone());
|
|
}
|
|
});
|
|
references
|
|
}
|
|
pub fn rename_parameter(&mut self, old: &str, new: &str) {
|
|
self.visit_parameters(&mut |text, expression| {
|
|
if expression {
|
|
*text = expr::rename_reference(text, old, new);
|
|
} else if text == old {
|
|
*text = new.to_string();
|
|
}
|
|
});
|
|
}
|
|
pub(super) fn clone_for_targets(&self, maps: &super::ConfigCloneMaps) -> Option<Self> {
|
|
let mapped = match &self.effect {
|
|
RuleEffect::DriveParameter { parameter, .. }
|
|
| RuleEffect::ParameterRange { parameter, .. }
|
|
| RuleEffect::AllowParameterValues { parameter, .. } => maps.parameters.contains_key(parameter),
|
|
RuleEffect::DriveSuppression { feature, .. } => maps.features.contains_key(feature),
|
|
};
|
|
if !mapped {
|
|
return None;
|
|
}
|
|
let mut copy = self.clone();
|
|
copy.visit_parameters(&mut |text, expression| {
|
|
*text = if expression {
|
|
super::clone::remap_expression(text, &maps.parameters)
|
|
} else {
|
|
maps.parameters.get(text).cloned().unwrap_or_else(|| text.clone())
|
|
};
|
|
});
|
|
fn remap(condition: &mut RuleCondition, maps: &super::ConfigCloneMaps) {
|
|
match condition {
|
|
RuleCondition::Suppression { feature, .. } => {
|
|
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
|
}
|
|
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
|
for condition in conditions {
|
|
remap(condition, maps);
|
|
}
|
|
}
|
|
RuleCondition::Not { condition } => remap(condition, maps),
|
|
_ => {}
|
|
}
|
|
}
|
|
remap(&mut copy.condition, maps);
|
|
if let RuleEffect::DriveSuppression { feature, .. } = &mut copy.effect {
|
|
*feature = maps.features.get(feature).copied().unwrap_or(*feature);
|
|
}
|
|
Some(copy)
|
|
}
|
|
fn visit_parameters(&mut self, visit: &mut impl FnMut(&mut String, bool)) {
|
|
fn walk(c: &mut RuleCondition, visit: &mut impl FnMut(&mut String, bool)) {
|
|
match c {
|
|
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => {
|
|
for c in conditions {
|
|
walk(c, visit);
|
|
}
|
|
}
|
|
RuleCondition::Not { condition } => walk(condition, visit),
|
|
RuleCondition::Parameter { parameter, expression, .. } => {
|
|
visit(parameter, false);
|
|
visit(expression, true);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
walk(&mut self.condition, visit);
|
|
match &mut self.effect {
|
|
RuleEffect::DriveParameter { parameter, expression } => {
|
|
visit(parameter, false);
|
|
visit(expression, true);
|
|
}
|
|
RuleEffect::ParameterRange { parameter, minimum, maximum } => {
|
|
visit(parameter, false);
|
|
visit(minimum, true);
|
|
visit(maximum, true);
|
|
}
|
|
RuleEffect::AllowParameterValues { parameter, expressions } => {
|
|
visit(parameter, false);
|
|
for e in expressions {
|
|
visit(e, true);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
pub fn references_features(&self, ids: &[u64]) -> bool {
|
|
fn condition(c: &RuleCondition, ids: &[u64]) -> bool {
|
|
match c {
|
|
RuleCondition::Suppression { feature, .. } => ids.contains(feature),
|
|
RuleCondition::All { conditions } | RuleCondition::Any { conditions } => conditions.iter().any(|c| condition(c, ids)),
|
|
RuleCondition::Not { condition: c } => condition(c, ids),
|
|
_ => false,
|
|
}
|
|
}
|
|
condition(&self.condition, ids) || matches!(&self.effect,RuleEffect::DriveSuppression {feature,..} if ids.contains(feature))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::ConfigRow;
|
|
fn design() -> Document {
|
|
let mut doc = Document::new("Rules");
|
|
doc.set_param("width", "10 mm", None, None).unwrap();
|
|
doc.set_param("depth", "5 mm", None, None).unwrap();
|
|
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
|
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
|
doc
|
|
}
|
|
fn rule(id: &str, condition: RuleCondition, effect: RuleEffect) -> ConfigRule {
|
|
ConfigRule { id: id.into(), name: id.into(), enabled: true, length_unit: None, condition, effect }
|
|
}
|
|
#[test]
|
|
fn ordered_rules_derive_values_without_overwriting_rows() {
|
|
let mut doc = design();
|
|
doc.configs.rules = vec![
|
|
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "1 in".into() }),
|
|
rule(
|
|
"second",
|
|
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Gt, expression: "20 mm".into() },
|
|
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
|
|
),
|
|
];
|
|
let source = doc.configs.rows.clone();
|
|
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
|
assert!(trace.iter().all(|entry| entry.matched));
|
|
assert!((doc.param_values().0["depth"].v - 12.7).abs() < 1e-9);
|
|
assert_eq!(doc.configs.rows, source);
|
|
assert!(doc.configuration_matches());
|
|
doc.configs.rules.swap(0, 1);
|
|
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
|
assert!(!trace[0].matched);
|
|
assert_eq!(doc.param_values().0["depth"].v, 5.0);
|
|
}
|
|
#[test]
|
|
fn restrictions_intersect_and_fail_atomically_with_named_rule() {
|
|
let mut doc = design();
|
|
doc.configs.rules = vec![
|
|
rule(
|
|
"range",
|
|
RuleCondition::Always,
|
|
RuleEffect::ParameterRange { parameter: "width".into(), minimum: "5 mm".into(), maximum: "1 in".into() },
|
|
),
|
|
rule(
|
|
"choices",
|
|
RuleCondition::Always,
|
|
RuleEffect::AllowParameterValues { parameter: "width".into(), expressions: vec!["1 cm".into(), "20 mm".into()] },
|
|
),
|
|
];
|
|
doc.apply_configuration("Default").unwrap();
|
|
doc.configs.rows[0].cells[0] = "15 mm".into();
|
|
let before = doc.clone();
|
|
assert!(doc.apply_configuration("Default").unwrap_err().to_string().contains("choices"));
|
|
assert_eq!(doc, before);
|
|
doc.configs.rules[0].effect = RuleEffect::ParameterRange { parameter: "width".into(), minimum: "1 deg".into(), maximum: "90 deg".into() };
|
|
assert!(doc.apply_configuration("Default").is_err());
|
|
}
|
|
#[test]
|
|
fn rule_references_follow_rename_and_prevent_delete() {
|
|
let mut doc = design();
|
|
doc.configs.rules.push(rule(
|
|
"derive",
|
|
RuleCondition::Parameter { parameter: "width".into(), operator: RuleOperator::Ge, expression: "depth".into() },
|
|
RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width / 2".into() },
|
|
));
|
|
assert!(doc.remove_param("width").is_err());
|
|
doc.rename_param("width", "span").unwrap();
|
|
let refs = doc.configs.rules[0].parameter_references();
|
|
assert!(refs.contains(&"span".into()));
|
|
assert!(!refs.contains(&"width".into()));
|
|
doc.apply_configuration("Default").unwrap();
|
|
assert_eq!(doc.param_values().0["depth"].v, 5.0);
|
|
}
|
|
#[test]
|
|
fn angle_and_unitless_comparisons_keep_their_dimensions() {
|
|
let mut doc = design();
|
|
doc.set_param("angle", "45 deg", Some("deg"), None).unwrap();
|
|
doc.set_param("count", "1", Some(""), None).unwrap();
|
|
doc.configs.columns.push(Column::Param("count".into()));
|
|
doc.configs.rows[0].cells.push("1".into());
|
|
doc.configs.rules.push(rule(
|
|
"angle-drive",
|
|
RuleCondition::Parameter { parameter: "angle".into(), operator: RuleOperator::Gt, expression: "1".into() },
|
|
RuleEffect::DriveParameter { parameter: "count".into(), expression: "2".into() },
|
|
));
|
|
let trace = doc.apply_configuration_with_trace("Default").unwrap();
|
|
assert!(trace[0].matched);
|
|
assert_eq!(doc.param_values().0["count"].v, 2.0);
|
|
doc.configs.rules[0].condition =
|
|
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "1 deg".into() };
|
|
let before = doc.clone();
|
|
assert!(doc.apply_configuration("Default").is_err());
|
|
assert_eq!(doc, before);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_rule_budgets_are_checked_before_deserialization() {
|
|
let valid = serde_json::to_value(rule(
|
|
"r",
|
|
RuleCondition::Always,
|
|
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
|
|
))
|
|
.unwrap();
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[valid.clone()]}})).is_ok());
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":null}})).is_err());
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":vec![valid.clone();129]}})).is_err());
|
|
let mut condition = serde_json::json!({"type":"always"});
|
|
for _ in 0..8 {
|
|
condition = serde_json::json!({"type":"not","condition":condition});
|
|
}
|
|
let mut deep = valid.clone();
|
|
deep["condition"] = condition;
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[deep]}})).is_err());
|
|
let mut wide = valid.clone();
|
|
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[wide]}})).is_err());
|
|
let mut long = valid.clone();
|
|
long["effect"]["expression"] = serde_json::json!("a".repeat(4097));
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[long]}})).is_err());
|
|
let mut malformed = valid;
|
|
malformed["condition"] = serde_json::json!({"type":"any","conditions":{}});
|
|
assert!(validate_rules_json(&serde_json::json!({"configs":{"rules":[malformed]}})).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn compound_conditions_short_circuit_guarded_numeric_expressions() {
|
|
let mut doc = design();
|
|
doc.set_param("count", "0", Some(""), None).unwrap();
|
|
let guard = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "0".into() };
|
|
let invalid_when_zero = RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Gt, expression: "1 / count".into() };
|
|
doc.configs.rules = vec![rule(
|
|
"guarded",
|
|
RuleCondition::All { conditions: vec![guard, invalid_when_zero.clone()] },
|
|
RuleEffect::DriveParameter { parameter: "width".into(), expression: "20 mm".into() },
|
|
)];
|
|
assert!(!doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
|
|
assert_eq!(doc.param_values().0["width"].v, 10.0);
|
|
doc.configs.rules[0].condition = RuleCondition::Any {
|
|
conditions: vec![
|
|
RuleCondition::Parameter { parameter: "count".into(), operator: RuleOperator::Eq, expression: "0".into() },
|
|
invalid_when_zero,
|
|
],
|
|
};
|
|
assert!(doc.apply_configuration_with_trace("Default").unwrap()[0].matched);
|
|
assert_eq!(doc.param_values().0["width"].v, 20.0);
|
|
}
|
|
|
|
#[test]
|
|
fn saved_documents_reject_malformed_deep_and_oversized_rules_before_typed_decode() {
|
|
let valid = serde_json::to_value(rule(
|
|
"r",
|
|
RuleCondition::Always,
|
|
RuleEffect::DriveParameter { parameter: "width".into(), expression: "2 mm".into() },
|
|
))
|
|
.unwrap();
|
|
let mut deep = valid.clone();
|
|
let mut condition = serde_json::json!({"type":"always"});
|
|
for _ in 0..8 {
|
|
condition = serde_json::json!({"type":"not","condition":condition});
|
|
}
|
|
deep["condition"] = condition;
|
|
let mut wide = valid.clone();
|
|
wide["condition"] = serde_json::json!({"type":"all","conditions":vec![serde_json::json!({"type":"always"});64]});
|
|
let mut invalid_context = valid.clone();
|
|
invalid_context["length_unit"] = serde_json::json!("mile".repeat(1000));
|
|
for invalid in [
|
|
Value::Null,
|
|
serde_json::json!([deep]),
|
|
serde_json::json!([wide]),
|
|
serde_json::json!(vec![valid; 129]),
|
|
serde_json::json!([invalid_context]),
|
|
] {
|
|
let mut saved = serde_json::to_value(design()).unwrap();
|
|
saved["configs"]["rules"] = invalid;
|
|
let error = crate::format::read(&saved.to_string()).unwrap_err().to_string();
|
|
assert!(error.contains("configuration rules"), "saved boundary must report bounded rule validation: {error}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cross_unit_effective_literals_keep_declared_drive_cycle_checks() {
|
|
let mut doc = Document::new("Cross-unit drive graph");
|
|
doc.add_feature(
|
|
crate::FeatureKind::Box {
|
|
corner: ["0".into(), "0".into(), "0".into()],
|
|
length: "10 mm".into(),
|
|
width: "5 mm".into(),
|
|
height: "10 mm".into(),
|
|
operation: crate::Operation::NewBody,
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
let width = doc.features[0].param_names[0].clone();
|
|
let depth = doc.features[0].param_names[1].clone();
|
|
doc.rename_param(&width, "width").unwrap();
|
|
doc.rename_param(&depth, "depth").unwrap();
|
|
doc.configs.columns = vec![Column::Param("width".into()), Column::Param("depth".into())];
|
|
doc.configs.rows.push(ConfigRow { name: "Default".into(), cells: vec!["10 mm".into(), "5 mm".into()] });
|
|
doc.units = "in".into();
|
|
let mut first =
|
|
rule("first", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "width".into(), expression: "depth + 1".into() });
|
|
first.length_unit = Some("mm".into());
|
|
let mut second =
|
|
rule("second", RuleCondition::Always, RuleEffect::DriveParameter { parameter: "depth".into(), expression: "width + 1".into() });
|
|
second.length_unit = Some("mm".into());
|
|
doc.configs.rules = vec![first];
|
|
doc.apply_configuration("Default").unwrap();
|
|
assert_eq!(doc.config_current(&Column::Param("width".into())).as_deref(), Some("6 mm"));
|
|
doc.configs.rules.push(second);
|
|
let before = doc.clone();
|
|
assert!(doc.apply_configuration("Default").is_err());
|
|
assert_eq!(doc, before);
|
|
}
|
|
}
|