use concinnity_cook::authoring::registry::RegisteredType;
use serde_json::{Map, Value};
pub(crate) const FIELD_POOL: usize = 14;
pub(crate) const FIELD_POOL_MAX: usize = 28;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FieldKind {
Str,
Int,
Float,
Bool,
Vec { len: usize, color: bool },
Enum,
Ref { target: &'static str },
Array,
}
impl FieldKind {
pub(crate) fn has_text_input(self) -> bool {
!matches!(
self,
FieldKind::Bool
| FieldKind::Enum
| FieldKind::Ref { .. }
| FieldKind::Array
| FieldKind::Vec { color: false, .. }
)
}
}
pub(crate) const NONE_LABEL: &str = "(none)";
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct FormField {
pub key: String,
pub kind: FieldKind,
pub initial: String,
pub boolval: bool,
pub variants: Vec<String>,
pub variant_idx: usize,
}
fn kind_of(key: &str, v: &Value) -> Option<FieldKind> {
match v {
Value::String(_) => Some(FieldKind::Str),
Value::Bool(_) => Some(FieldKind::Bool),
Value::Number(n) => Some(if n.is_i64() || n.is_u64() {
FieldKind::Int
} else {
FieldKind::Float
}),
Value::Array(a) if (2..=4).contains(&a.len()) && a.iter().all(Value::is_number) => {
Some(FieldKind::Vec {
len: a.len(),
color: is_color_key(key),
})
}
_ => None,
}
}
#[derive(Clone, Copy)]
struct TypeMeta {
ct: Option<RegisteredType>,
refs: &'static [(&'static str, &'static str)],
}
impl TypeMeta {
fn of(ty: &str) -> Self {
let ct = RegisteredType::parse(ty);
let refs = ct.map(|c| c.ref_fields()).unwrap_or(&[]);
TypeMeta { ct, refs }
}
fn ref_target(&self, field: &str) -> Option<&'static str> {
self.refs
.iter()
.find(|(name, _)| *name == field)
.map(|(_, target)| *target)
}
}
pub(crate) fn set_ref_options(field: &mut FormField, names: &[String]) {
let mut variants = Vec::with_capacity(names.len() + 2);
variants.push(NONE_LABEL.to_string());
variants.extend(names.iter().cloned());
if !field.initial.is_empty() && !variants.iter().any(|v| v == &field.initial) {
variants.push(field.initial.clone());
}
field.variant_idx = variants
.iter()
.position(|v| v == &field.initial)
.unwrap_or(0);
field.variants = variants;
}
fn is_color_key(key: &str) -> bool {
let k = key.to_ascii_lowercase();
["color", "colour", "tint", "background", "emissive"]
.iter()
.any(|needle| k.contains(needle))
}
fn value_text(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => number_text(n),
Value::Bool(b) => b.to_string(),
Value::Array(a) => a.iter().map(value_text).collect::<Vec<_>>().join(", "),
_ => String::new(),
}
}
fn number_text(n: &serde_json::Number) -> String {
if n.is_i64() || n.is_u64() {
return n.to_string();
}
match n.as_f64() {
Some(v) if (v as f32) as f64 == v => format!("{:?}", v as f32),
_ => n.to_string(),
}
}
pub(crate) fn base_args(ty: &str) -> Map<String, Value> {
RegisteredType::parse(ty)
.and_then(|ct| ct.registration().default_args)
.and_then(|v| match v {
Value::Object(m) => Some(m),
_ => None,
})
.unwrap_or_default()
}
const MAX_NEST_DEPTH: usize = 2;
#[cfg(test)]
pub(crate) fn fields_for(ty: &str, seed: Option<&Map<String, Value>>) -> Vec<FormField> {
fields_for_with(ty, seed, &std::collections::HashSet::new())
}
pub(crate) fn fields_for_with(
ty: &str,
seed: Option<&Map<String, Value>>,
expanded: &std::collections::HashSet<String>,
) -> Vec<FormField> {
let base = base_args(ty);
let meta = TypeMeta::of(ty);
let mut out = Vec::new();
collect_fields(meta, "", &base, seed, expanded, 0, &mut out);
out
}
fn collect_fields(
meta: TypeMeta,
prefix: &str,
obj: &Map<String, Value>,
root_seed: Option<&Map<String, Value>>,
expanded: &std::collections::HashSet<String>,
depth: usize,
out: &mut Vec<FormField>,
) {
for (key, def) in obj {
let path = if prefix.is_empty() {
key.clone()
} else {
format!("{prefix}.{key}")
};
collect_value(meta, &path, def, root_seed, expanded, depth, out);
}
}
fn collect_value(
meta: TypeMeta,
path: &str,
def: &Value,
root_seed: Option<&Map<String, Value>>,
expanded: &std::collections::HashSet<String>,
depth: usize,
out: &mut Vec<FormField>,
) {
let is_root = !path.contains('.');
let leaf = path.rsplit('.').next().unwrap_or(path);
let ref_target = meta.ref_target(path);
if ref_target.is_none()
&& depth < MAX_NEST_DEPTH
&& let Value::Object(nested) = def
&& !nested.is_empty()
&& !seed_overrides_with_non_object(root_seed, path)
{
collect_fields(meta, path, nested, root_seed, expanded, depth + 1, out);
return;
}
let kind = match ref_target {
Some(target) => Some(FieldKind::Ref { target }),
None => kind_of(leaf, def),
};
if let Some(mut kind) = kind {
let cur = root_seed.and_then(|s| get_at_path(s, path)).unwrap_or(def);
let mut variants = Vec::new();
let mut variant_idx = 0;
if is_root
&& matches!(kind, FieldKind::Str)
&& let Some(v) = meta.ct.and_then(|c| c.field_enum_variants(leaf))
{
variant_idx = cur
.as_str()
.and_then(|s| v.iter().position(|x| x == s))
.unwrap_or(0);
variants = v;
kind = FieldKind::Enum;
}
let boolval = matches!(kind, FieldKind::Bool)
&& cur.as_bool().or_else(|| def.as_bool()).unwrap_or(false);
let initial = match kind {
FieldKind::Bool | FieldKind::Enum => String::new(),
FieldKind::Ref { .. } => cur.as_str().unwrap_or_default().to_string(),
_ => value_text(cur),
};
out.push(FormField {
key: path.to_string(),
kind,
initial,
boolval,
variants,
variant_idx,
});
if let FieldKind::Vec { len, color: false } = kind
&& expanded.contains(path)
{
let cur = root_seed.and_then(|s| get_at_path(s, path)).unwrap_or(def);
let arr = cur.as_array();
for i in 0..len {
let elem = arr.and_then(|a| a.get(i));
let ekind = elem
.and_then(|e| kind_of("", e))
.unwrap_or(FieldKind::Float);
out.push(FormField {
key: format!("{path}.{i}"),
kind: ekind,
initial: elem.map(value_text).unwrap_or_default(),
boolval: false,
variants: Vec::new(),
variant_idx: 0,
});
}
}
return;
}
if ref_target.is_none()
&& depth < MAX_NEST_DEPTH
&& let Value::Array(def_arr) = def
{
let cur_arr = root_seed
.and_then(|s| get_at_path(s, path))
.and_then(Value::as_array)
.unwrap_or(def_arr);
let template = cur_arr.first().or_else(|| def_arr.first());
if template.is_none_or(Value::is_number) {
return;
}
out.push(FormField {
key: path.to_string(),
kind: FieldKind::Array,
initial: String::new(),
boolval: false,
variants: Vec::new(),
variant_idx: cur_arr.len(),
});
for (i, elem) in cur_arr.iter().enumerate() {
let elem_path = format!("{path}.{i}");
collect_value(meta, &elem_path, elem, root_seed, expanded, depth + 1, out);
}
}
}
fn seed_overrides_with_non_object(root_seed: Option<&Map<String, Value>>, path: &str) -> bool {
matches!(root_seed.and_then(|s| get_at_path(s, path)), Some(v) if !v.is_object())
}
fn get_at_path<'a>(obj: &'a Map<String, Value>, path: &str) -> Option<&'a Value> {
let mut parts = path.split('.');
let mut cur = obj.get(parts.next()?)?;
for p in parts {
cur = match cur {
Value::Object(m) => m.get(p)?,
Value::Array(a) => a.get(p.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(cur)
}
fn set_at_path(obj: &mut Map<String, Value>, path: &str, val: Value) {
let parts: Vec<&str> = path.split('.').collect();
let (leaf, parents) = parts.split_last().expect("a path has at least one segment");
if parents.is_empty() {
obj.insert(leaf.to_string(), val);
return;
}
let mut cur = obj
.entry(parents[0].to_string())
.or_insert_with(|| Value::Object(Map::new()));
for seg in &parents[1..] {
cur = match cur {
Value::Array(a) => {
let Some(v) = seg.parse::<usize>().ok().and_then(|i| a.get_mut(i)) else {
return;
};
v
}
other => {
if !other.is_object() {
*other = Value::Object(Map::new());
}
other
.as_object_mut()
.expect("just ensured an object")
.entry((*seg).to_string())
.or_insert_with(|| Value::Object(Map::new()))
}
};
}
match cur {
Value::Array(a) => {
if let Some(slot) = leaf.parse::<usize>().ok().and_then(|i| a.get_mut(i)) {
*slot = val;
}
}
other => {
if !other.is_object() {
*other = Value::Object(Map::new());
}
other
.as_object_mut()
.expect("just ensured an object")
.insert(leaf.to_string(), val);
}
}
}
fn coerce(field: &FormField, text: &str, default: &Value) -> Value {
match field.kind {
FieldKind::Str => Value::String(text.to_string()),
FieldKind::Int => text
.trim()
.parse::<i64>()
.map(Value::from)
.unwrap_or_else(|_| default.clone()),
FieldKind::Float => text
.trim()
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.map(Value::Number)
.unwrap_or_else(|| default.clone()),
FieldKind::Bool => Value::Bool(field.boolval),
FieldKind::Vec { len, .. } => coerce_array(text, len, default),
FieldKind::Enum => field
.variants
.get(field.variant_idx)
.map(|v| Value::String(v.clone()))
.unwrap_or_else(|| default.clone()),
FieldKind::Ref { .. } => match field.variants.get(field.variant_idx) {
Some(v) if v != NONE_LABEL => Value::String(v.clone()),
_ => Value::Null,
},
FieldKind::Array => default.clone(),
}
}
fn coerce_array(text: &str, len: usize, default: &Value) -> Value {
let parts: Vec<&str> = text
.split(|c: char| c == ',' || c.is_whitespace())
.filter(|s| !s.is_empty())
.collect();
if parts.len() != len {
return default.clone();
}
let default_elems = default.as_array();
let mut out = Vec::with_capacity(len);
for (i, p) in parts.iter().enumerate() {
let want_int = default_elems
.and_then(|a| a.get(i))
.map(|e| e.is_i64() || e.is_u64())
.unwrap_or(false);
if want_int {
match p.parse::<i64>() {
Ok(n) => out.push(Value::from(n)),
Err(_) => return default.clone(),
}
} else {
match p.parse::<f64>().ok().and_then(serde_json::Number::from_f64) {
Some(n) => out.push(Value::Number(n)),
None => return default.clone(),
}
}
}
Value::Array(out)
}
pub(crate) fn assemble(
ty: &str,
editing_args: Option<&Map<String, Value>>,
fields: &[FormField],
texts: &[String],
) -> Map<String, Value> {
let defaults = base_args(ty);
let mut out = defaults.clone();
if let Some(existing) = editing_args {
for (k, v) in existing {
out.insert(k.clone(), v.clone());
}
}
for (i, field) in fields.iter().enumerate() {
if field.kind == FieldKind::Array
|| matches!(field.kind, FieldKind::Vec { color: false, .. })
{
continue;
}
let fallback = get_at_path(&out, &field.key)
.cloned()
.unwrap_or(Value::Null);
let text = texts.get(i).map(String::as_str).unwrap_or("");
set_at_path(&mut out, &field.key, coerce(field, text, &fallback));
}
out
}
pub(crate) fn working_args(ty: &str, editing: Option<&Map<String, Value>>) -> Map<String, Value> {
let mut out = base_args(ty);
if let Some(e) = editing {
for (k, v) in e {
out.insert(k.clone(), v.clone());
}
}
out
}
pub(crate) fn current_text(args: &Map<String, Value>, path: &str) -> String {
get_at_path(args, path).map(value_text).unwrap_or_default()
}
pub(crate) fn array_elem_template(
ty: &str,
args: &Map<String, Value>,
path: &str,
) -> Option<Value> {
let first_of = |m: &Map<String, Value>| {
get_at_path(m, path)
.and_then(Value::as_array)
.and_then(|a| a.first().cloned())
};
first_of(args).or_else(|| first_of(&base_args(ty)))
}
pub(crate) fn add_array_elem(ty: &str, args: &mut Map<String, Value>, path: &str) -> bool {
let Some(template) = array_elem_template(ty, args, path) else {
return false;
};
if let Some(Value::Array(a)) = get_at_path_mut(args, path) {
a.push(template);
return true;
}
false
}
pub(crate) fn remove_array_elem(args: &mut Map<String, Value>, path: &str) -> bool {
if let Some(Value::Array(a)) = get_at_path_mut(args, path)
&& !a.is_empty()
{
a.pop();
return true;
}
false
}
fn get_at_path_mut<'a>(obj: &'a mut Map<String, Value>, path: &str) -> Option<&'a mut Value> {
let mut parts = path.split('.');
let mut cur = obj.get_mut(parts.next()?)?;
for p in parts {
cur = match cur {
Value::Object(m) => m.get_mut(p)?,
Value::Array(a) => a.get_mut(p.parse::<usize>().ok()?)?,
_ => return None,
};
}
Some(cur)
}
pub(crate) fn validate(ty: &str, name: &str, args: &Map<String, Value>) -> Result<(), String> {
let Some(ct) = RegisteredType::parse(ty) else {
return Err(format!("unknown asset type '{ty}'"));
};
if ct.is_resource() {
let args = Value::Object(args.clone());
ct.normalized_args(&args).map_err(|e| e.to_string())?;
return concinnity_cook::validate_asset(ty, name, &args);
}
ct.reserialize_args(&Value::Object(args.clone()))
.map(|_| ())
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fields_for_pointlight_exposes_scalars_and_vectors() {
let fields = fields_for("PointLight", None);
assert!(!fields.is_empty(), "PointLight exposes editable fields");
let field = |k: &str| fields.iter().find(|f| f.key == k).cloned();
assert_eq!(
field("color").map(|f| f.kind),
Some(FieldKind::Vec {
len: 3,
color: true
}),
"color is an editable RGB vector with a swatch"
);
assert_eq!(
field("position").map(|f| f.kind),
Some(FieldKind::Vec {
len: 3,
color: false
}),
"position is an editable vector, not a colour"
);
assert_eq!(field("intensity").map(|f| f.kind), Some(FieldKind::Float));
assert_eq!(field("color").unwrap().initial, "1.0, 1.0, 1.0");
}
#[test]
fn kind_of_only_accepts_small_numeric_arrays() {
use serde_json::json;
assert_eq!(
kind_of("position", &json!([1.0, 2.0, 3.0])),
Some(FieldKind::Vec {
len: 3,
color: false
})
);
assert_eq!(
kind_of("half_size", &json!([1.0, 1.0])),
Some(FieldKind::Vec {
len: 2,
color: false
})
);
assert_eq!(
kind_of("tint", &json!([1.0, 1.0, 1.0, 1.0])),
Some(FieldKind::Vec {
len: 4,
color: true
})
);
assert_eq!(
kind_of("background", &json!([0.0, 0.0, 0.0, 0.0])),
Some(FieldKind::Vec {
len: 4,
color: true
})
);
assert_eq!(
kind_of("half_extents", &json!([10.0, 5.0, 10.0])),
Some(FieldKind::Vec {
len: 3,
color: false
})
);
assert_eq!(kind_of("params", &json!(vec![0.0_f64; 32])), None);
assert_eq!(kind_of("lod_distances", &json!([])), None);
assert_eq!(kind_of("x", &json!([1.0])), None);
assert_eq!(kind_of("waves", &json!([{"a": 1}])), None);
}
#[test]
fn coerce_array_parses_round_trips_and_falls_back() {
use serde_json::json;
let def = json!([1.0, 1.0, 1.0]);
assert_eq!(
coerce_array("0.2, 0.4, 0.6", 3, &def),
json!([0.2, 0.4, 0.6])
);
assert_eq!(coerce_array("0.2 0.4 0.6", 3, &def), json!([0.2, 0.4, 0.6]));
assert_eq!(coerce_array("0.2, 0.4", 3, &def), def);
assert_eq!(coerce_array("0.2, x, 0.6", 3, &def), def);
let idef = json!([0, 0, 0]);
assert_eq!(coerce_array("16, 24, 16", 3, &idef), json!([16, 24, 16]));
assert_eq!(coerce_array("16, x, 16", 3, &idef), idef);
}
#[test]
fn value_text_renders_bools_and_blanks_unhandled_values() {
use serde_json::json;
assert_eq!(value_text(&Value::Bool(true)), "true");
assert_eq!(value_text(&json!([1.0, 2.0, 3.0])), "1.0, 2.0, 3.0");
assert_eq!(value_text(&Value::Null), "");
assert_eq!(value_text(&json!({"a": 1})), "");
}
#[test]
fn get_at_path_stops_at_a_scalar_segment() {
use serde_json::json;
let mut obj = Map::new();
obj.insert("scalar".to_string(), json!(3));
obj.insert("nested".to_string(), json!({"leaf": 7}));
assert!(get_at_path(&obj, "scalar.deeper").is_none());
assert_eq!(get_at_path(&obj, "nested.leaf"), Some(&json!(7)));
assert!(get_at_path(&obj, "missing").is_none());
}
#[test]
fn set_at_path_rebuilds_scalar_parents_and_leaves_as_objects() {
use serde_json::json;
let mut obj = Map::new();
obj.insert("a".to_string(), json!(1));
obj.insert("x".to_string(), json!(2));
set_at_path(&mut obj, "a.b", json!(5));
assert_eq!(get_at_path(&obj, "a.b"), Some(&json!(5)));
set_at_path(&mut obj, "x.y.z", json!(9));
assert_eq!(get_at_path(&obj, "x.y.z"), Some(&json!(9)));
}
#[test]
fn coerce_leaves_an_array_header_at_its_default() {
use serde_json::json;
let field = FormField {
key: "waves".to_string(),
kind: FieldKind::Array,
initial: String::new(),
boolval: false,
variants: Vec::new(),
variant_idx: 2,
};
let default = json!([{"amplitude": 1.0}]);
assert_eq!(coerce(&field, "ignored", &default), default);
}
#[test]
fn working_args_merges_an_edited_entry_over_defaults() {
use serde_json::json;
let mut editing = Map::new();
editing.insert("intensity".to_string(), json!(42.0));
let out = working_args("PointLight", Some(&editing));
assert_eq!(out.get("intensity"), Some(&json!(42.0)));
assert!(out.contains_key("color"));
}
#[test]
fn array_mutators_ignore_absent_or_scalar_paths() {
use serde_json::json;
let mut obj = Map::new();
obj.insert("scalar".to_string(), json!(1));
obj.insert("empty".to_string(), json!([]));
assert!(!remove_array_elem(&mut obj, "scalar.inner"));
assert!(!remove_array_elem(&mut obj, "empty"));
assert!(!remove_array_elem(&mut obj, "missing"));
let mut point = Map::new();
point.insert("position".to_string(), json!(3));
assert!(!add_array_elem("PointLight", &mut point, "position"));
}
#[test]
fn validate_rejects_an_unknown_type() {
let err = validate("NotARealAssetType", "probe", &Map::new()).unwrap_err();
assert!(err.contains("unknown asset type"), "got: {err}");
}
#[test]
fn fields_for_detects_a_string_enum_as_a_cycling_field() {
let fields = fields_for("Sprite", None);
let fit = fields.iter().find(|f| f.key == "fit").expect("fit field");
assert_eq!(fit.kind, FieldKind::Enum);
assert_eq!(fit.variants, vec!["fit", "cover", "bottom"]);
assert_eq!(fit.variant_idx, 0, "Sprite's default fit selects variant 0");
let hr = fields_for("HitRegion", None);
assert_eq!(
hr.iter().find(|f| f.key == "action").unwrap().kind,
FieldKind::Str
);
}
#[test]
fn enum_field_coerces_to_its_selected_variant() {
let mut fields = fields_for("TextLabel", None);
let idx = fields
.iter()
.position(|f| f.key == "align")
.expect("align field");
assert_eq!(fields[idx].kind, FieldKind::Enum);
let center = fields[idx]
.variants
.iter()
.position(|v| v == "center")
.expect("a center variant");
fields[idx].variant_idx = center;
let texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
let args = assemble("TextLabel", None, &fields, &texts);
assert_eq!(args["align"], "center");
assert!(
validate("TextLabel", "probe", &args).is_ok(),
"the picked variant cooks"
);
}
#[test]
fn ref_field_detects_target_options_and_coerces() {
let fields = fields_for("Decal", None);
let tex = fields
.iter()
.find(|f| f.key == "texture")
.expect("texture ref field");
assert_eq!(tex.kind, FieldKind::Ref { target: "Texture" });
assert_eq!(tex.initial, "", "an unset ref stashes no target name");
let mut f = tex.clone();
set_ref_options(&mut f, &["grass".into(), "stone".into()]);
assert_eq!(f.variants, vec![NONE_LABEL, "grass", "stone"]);
assert_eq!(f.variant_idx, 0, "no current target -> (none)");
assert_eq!(coerce(&f, "", &Value::Null), Value::Null);
f.variant_idx = 2;
assert_eq!(coerce(&f, "", &Value::Null), Value::String("stone".into()));
}
#[test]
fn resource_type_material_texture_fields_are_ref_pickers() {
let fields = fields_for("Material", None);
for key in ["albedo", "normal_map", "emissive_map", "orm_map"] {
let f = fields
.iter()
.find(|f| f.key == key)
.unwrap_or_else(|| panic!("Material `{key}` field"));
assert_eq!(
f.kind,
FieldKind::Ref { target: "Texture" },
"Material `{key}` should be a Texture ref picker"
);
}
}
#[test]
fn resource_type_environment_map_exposes_its_source_and_tuning() {
let mut seed = base_args("EnvironmentMap");
seed.insert("source".into(), Value::String("hdri/studio.hdr".into()));
let fields = fields_for("EnvironmentMap", Some(&seed));
let source = fields
.iter()
.find(|f| f.key == "source")
.expect("source field");
assert_eq!(source.initial, "hdri/studio.hdr");
for key in [
"prefilter_face_size",
"irradiance_face_size",
"prefilter_samples",
"prefilter_clamp",
] {
assert!(
fields.iter().any(|f| f.key == key),
"EnvironmentMap `{key}` should be editable"
);
}
let texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
let args = assemble("EnvironmentMap", Some(&seed), &fields, &texts);
validate("EnvironmentMap", "probe", &args).expect("an unedited round-trip stays valid");
}
#[test]
fn set_ref_options_selects_the_current_target_when_editing() {
let mut seed = base_args("Decal");
seed.insert("texture".into(), Value::String("grass".into()));
let fields = fields_for("Decal", Some(&seed));
let mut tex = fields
.into_iter()
.find(|f| f.key == "texture")
.expect("texture ref field");
assert_eq!(tex.initial, "grass", "the current ref name is carried");
set_ref_options(&mut tex, &["stone".into(), "grass".into()]);
assert_eq!(tex.variants[tex.variant_idx], "grass");
}
#[test]
fn set_ref_options_preserves_an_unlisted_current_target() {
let mut field = FormField {
key: "texture".into(),
kind: FieldKind::Ref { target: "Texture" },
initial: "imported_tex".into(),
boolval: false,
variants: Vec::new(),
variant_idx: 0,
};
set_ref_options(&mut field, &["grass".into()]);
assert!(field.variants.contains(&"imported_tex".to_string()));
assert_eq!(field.variants[field.variant_idx], "imported_tex");
assert_eq!(
coerce(&field, "", &Value::Null),
Value::String("imported_tex".into())
);
}
#[test]
fn newly_offered_types_derive_expected_fields() {
let screen = fields_for("Screen", None);
let vk = |k: &str| screen.iter().find(|f| f.key == k).map(|f| f.kind);
assert_eq!(vk("initial"), Some(FieldKind::Bool));
assert_eq!(vk("fade_in_secs"), Some(FieldKind::Float));
let block = fields_for("BlockType", None);
let bk = |k: &str| block.iter().find(|f| f.key == k).map(|f| f.kind);
assert_eq!(bk("solid"), Some(FieldKind::Bool));
assert_eq!(
bk("uv_min"),
Some(FieldKind::Vec {
len: 2,
color: false
})
);
let fps = fields_for("FpsCounter", None);
assert_eq!(
fps.iter().find(|f| f.key == "label").map(|f| f.kind),
Some(FieldKind::Ref {
target: "TextLabel"
}),
"FpsCounter.label is a reference picker"
);
}
#[test]
fn fields_for_flattens_a_nested_object() {
let fields = fields_for("Camera3D", None);
let kind = |k: &str| fields.iter().find(|f| f.key == k).map(|f| f.kind);
assert_eq!(kind("fov_y_degrees"), Some(FieldKind::Float));
assert_eq!(kind("controller.free_fly"), Some(FieldKind::Bool));
assert_eq!(kind("controller.move_speed"), Some(FieldKind::Float));
assert_eq!(
kind("controller.bounds_min"),
Some(FieldKind::Vec {
len: 3,
color: false
})
);
let ff = fields
.iter()
.find(|f| f.key == "controller.free_fly")
.unwrap();
assert!(ff.boolval, "controller.free_fly default is true");
assert!(
fields
.iter()
.all(|f| !f.key.starts_with("controller.follow")),
"a null nested Option is not descended into"
);
}
#[test]
fn assemble_writes_a_nested_field_back_into_its_object() {
let fields = fields_for("Camera3D", None);
let idx = fields
.iter()
.position(|f| f.key == "controller.move_speed")
.expect("controller.move_speed field");
let mut texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
texts[idx] = "7.5".into();
let args = assemble("Camera3D", None, &fields, &texts);
assert_eq!(
args["controller"]["move_speed"].as_f64(),
Some(7.5),
"the nested edit lands inside the sub-object"
);
assert!(
validate("Camera3D", "probe", &args).is_ok(),
"the nested edit cooks"
);
}
#[test]
fn assemble_preserves_sibling_nested_values() {
let seed = base_args("Camera3D");
let fields = fields_for("Camera3D", Some(&seed));
let idx = fields
.iter()
.position(|f| f.key == "controller.move_speed")
.unwrap();
let mut texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
texts[idx] = "2.0".into();
let args = assemble("Camera3D", Some(&seed), &fields, &texts);
assert_eq!(args["controller"]["move_speed"].as_f64(), Some(2.0));
assert_eq!(args["controller"]["sprint_multiplier"].as_f64(), Some(3.0));
}
#[test]
fn editing_a_null_nested_object_preserves_the_null() {
let mut seed = base_args("Camera3D");
seed.insert("controller".into(), Value::Null);
let fields = fields_for("Camera3D", Some(&seed));
assert!(
fields.iter().all(|f| !f.key.starts_with("controller")),
"a null nested object is not flattened into leaves"
);
let texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
let args = assemble("Camera3D", Some(&seed), &fields, &texts);
assert_eq!(
args["controller"],
Value::Null,
"the authored null controller survives the edit"
);
assert!(
validate("Camera3D", "probe", &args).is_ok(),
"a null controller cooks"
);
}
#[test]
fn path_helpers_get_and_set_nested_values() {
let mut m = base_args("Camera3D");
assert!(
get_at_path(&m, "controller.free_fly")
.and_then(Value::as_bool)
.is_some()
);
set_at_path(&mut m, "controller.move_speed", Value::from(9.0));
assert_eq!(
get_at_path(&m, "controller.move_speed").and_then(Value::as_f64),
Some(9.0)
);
let mut empty = Map::new();
set_at_path(&mut empty, "a.b.c", Value::from(1));
assert_eq!(
get_at_path(&empty, "a.b.c").and_then(Value::as_i64),
Some(1)
);
set_at_path(&mut empty, "x", Value::from(2));
assert_eq!(get_at_path(&empty, "x").and_then(Value::as_i64), Some(2));
assert!(get_at_path(&empty, "a.b.missing").is_none());
}
#[test]
fn fields_for_exposes_an_array_header_and_element_leaves() {
let fields = fields_for("WaterSurface", None);
let waves = fields
.iter()
.find(|f| f.key == "waves")
.expect("a waves array header");
assert_eq!(waves.kind, FieldKind::Array);
assert_eq!(waves.variant_idx, 1, "the default has one wave");
assert_eq!(
fields
.iter()
.find(|f| f.key == "waves.0.amplitude")
.map(|f| f.kind),
Some(FieldKind::Float)
);
assert_eq!(
fields
.iter()
.find(|f| f.key == "waves.0.direction")
.map(|f| f.kind),
Some(FieldKind::Vec {
len: 2,
color: false
})
);
assert_eq!(
fields.iter().find(|f| f.key == "extent").map(|f| f.kind),
Some(FieldKind::Vec {
len: 2,
color: false
})
);
}
#[test]
fn add_and_remove_array_elem_grow_and_shrink_from_a_template() {
let mut args = base_args("WaterSurface");
let len =
|a: &Map<String, Value>| get_at_path(a, "waves").unwrap().as_array().unwrap().len();
assert_eq!(len(&args), 1);
assert!(add_array_elem("WaterSurface", &mut args, "waves"));
assert_eq!(len(&args), 2, "grew by a cloned template element");
assert!(
get_at_path(&args, "waves.1.amplitude").is_some(),
"the appended element has the template's shape"
);
assert!(remove_array_elem(&mut args, "waves"));
assert_eq!(len(&args), 1, "shrank");
assert!(remove_array_elem(&mut args, "waves"));
assert_eq!(len(&args), 0);
assert!(
add_array_elem("WaterSurface", &mut args, "waves"),
"an emptied array regrows from the default template"
);
assert_eq!(len(&args), 1);
}
#[test]
fn add_array_elem_without_a_template_is_a_no_op() {
let mut m = Map::new();
m.insert("xs".into(), Value::Array(vec![]));
assert!(!add_array_elem("PointLight", &mut m, "xs"));
assert!(
get_at_path(&m, "xs")
.unwrap()
.as_array()
.unwrap()
.is_empty()
);
}
#[test]
fn path_helpers_navigate_array_indices() {
let mut args = base_args("WaterSurface");
assert!(
get_at_path(&args, "waves.0.amplitude")
.and_then(Value::as_f64)
.is_some()
);
set_at_path(&mut args, "waves.0.amplitude", Value::from(9.5));
assert_eq!(
get_at_path(&args, "waves.0.amplitude").and_then(Value::as_f64),
Some(9.5)
);
set_at_path(&mut args, "waves.5.amplitude", Value::from(1.0));
assert!(get_at_path(&args, "waves.5.amplitude").is_none());
}
#[test]
fn expanding_a_vector_discloses_per_element_leaves() {
use std::collections::HashSet;
let collapsed = fields_for("PointLight", None);
assert_eq!(
collapsed
.iter()
.find(|f| f.key == "position")
.map(|f| f.kind),
Some(FieldKind::Vec {
len: 3,
color: false
})
);
assert!(
collapsed.iter().all(|f| !f.key.starts_with("position.")),
"a collapsed vector emits no element leaves"
);
let expanded = HashSet::from(["position".to_string()]);
let fields = fields_for_with("PointLight", None, &expanded);
assert!(fields.iter().any(|f| f.key == "position"));
for axis in ["position.0", "position.1", "position.2"] {
let leaf = fields
.iter()
.find(|f| f.key == axis)
.unwrap_or_else(|| panic!("missing element leaf {axis}"));
assert_eq!(leaf.kind, FieldKind::Float);
}
assert!(
fields.iter().all(|f| f.key != "position.3"),
"a 3-vector discloses exactly 3 leaves"
);
}
#[test]
fn expanding_never_touches_a_colour_vector() {
use std::collections::HashSet;
let expanded = HashSet::from(["color".to_string()]);
let fields = fields_for_with("PointLight", None, &expanded);
assert!(fields.iter().all(|f| !f.key.starts_with("color.")));
assert_eq!(
fields.iter().find(|f| f.key == "color").map(|f| f.kind),
Some(FieldKind::Vec {
len: 3,
color: true
})
);
}
#[test]
fn assemble_writes_a_disclosed_vector_element() {
use std::collections::HashSet;
let expanded = HashSet::from(["position".to_string()]);
let fields = fields_for_with("PointLight", None, &expanded);
let idx = fields
.iter()
.position(|f| f.key == "position.1")
.expect("a position.1 element leaf");
let mut texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
texts[idx] = "4.5".into();
let args = assemble("PointLight", None, &fields, &texts);
assert_eq!(
get_at_path(&args, "position")
.and_then(Value::as_array)
.map(|a| a.len()),
Some(3),
"the vector keeps its length"
);
assert_eq!(
get_at_path(&args, "position.1").and_then(Value::as_f64),
Some(4.5)
);
assert!(validate("PointLight", "probe", &args).is_ok());
}
#[test]
fn floats_display_shortened_but_round_trip() {
let num = |v: f64| serde_json::Number::from_f64(v).unwrap();
let long = num(0.05_f32 as f64).to_string();
assert!(long.len() > 6, "serde prints the long expansion: {long}");
assert_eq!(number_text(&num(0.05_f32 as f64)), "0.05");
assert_eq!(number_text(&num(1.0_f32 as f64)), "1.0");
assert_eq!(number_text(&serde_json::Number::from(42)), "42");
assert_eq!(number_text(&num(0.1)), num(0.1).to_string());
}
#[test]
fn assemble_writes_an_array_element_value() {
let fields = fields_for("WaterSurface", None);
let idx = fields
.iter()
.position(|f| f.key == "waves.0.amplitude")
.expect("a waves.0.amplitude field");
let mut texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
texts[idx] = "3.25".into();
let args = assemble("WaterSurface", None, &fields, &texts);
assert_eq!(
get_at_path(&args, "waves.0.amplitude").and_then(Value::as_f64),
Some(3.25)
);
assert!(
validate("WaterSurface", "probe", &args).is_ok(),
"the element edit cooks"
);
}
#[test]
fn fixed_numeric_array_is_not_a_growable_array() {
let fields = fields_for("SdfVolume", None);
assert!(
fields.iter().all(|f| !f.key.starts_with("params")),
"a fixed [f32; N] numeric array is neither an Array header nor element leaves"
);
assert!(
fields_for("WaterSurface", None)
.iter()
.any(|f| f.key == "waves" && f.kind == FieldKind::Array),
"an object array is still editable"
);
}
#[test]
fn fields_for_exposes_more_than_the_control_pool() {
let fields = fields_for("WaterSurface", None);
assert!(
fields.len() > FIELD_POOL,
"WaterSurface exposes {} fields, past the {FIELD_POOL}-slot pool",
fields.len()
);
assert!(
fields.iter().any(|f| f.key == "roughness"),
"a field past the pool is still derived, not truncated"
);
}
#[test]
fn current_text_renders_a_stored_value_or_empty() {
let mut args = base_args("WaterSurface");
assert_eq!(
current_text(&args, "roughness"),
value_text(&args["roughness"])
);
set_at_path(&mut args, "waves.0.amplitude", Value::from(2.5));
assert_eq!(current_text(&args, "waves.0.amplitude"), "2.5");
assert_eq!(current_text(&args, "no_such_field"), "");
}
#[test]
fn unknown_type_has_no_fields_and_empty_base() {
assert!(fields_for("NotARealType", None).is_empty());
assert!(base_args("NotARealType").is_empty());
}
#[test]
fn assemble_persists_an_edited_colour_vector() {
let fields = fields_for("PointLight", None);
let (idx, key) = fields
.iter()
.enumerate()
.find(|(_, f)| f.key == "color")
.map(|(i, f)| (i, f.key.clone()))
.expect("a color field");
let mut texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
texts[idx] = "0.5, 0.25, 0.75".into();
let args = assemble("PointLight", None, &fields, &texts);
assert_eq!(args[&key], serde_json::json!([0.5, 0.25, 0.75]));
assert!(
validate("PointLight", "probe", &args).is_ok(),
"the edit still cooks"
);
}
#[test]
fn assemble_overwrites_editable_and_keeps_the_rest() {
let fields = [
FormField {
key: "intensity".into(),
kind: FieldKind::Float,
initial: "1".into(),
boolval: false,
variants: Vec::new(),
variant_idx: 0,
},
FormField {
key: "on".into(),
kind: FieldKind::Bool,
initial: String::new(),
boolval: true,
variants: Vec::new(),
variant_idx: 0,
},
];
let mut base = Map::new();
base.insert("intensity".into(), Value::from(1.0));
base.insert("on".into(), Value::Bool(false));
base.insert("color".into(), serde_json::json!([1, 1, 1]));
let texts = ["2.5".to_string(), String::new()];
let out = {
let mut m = base.clone();
for (i, f) in fields.iter().enumerate() {
let def = base.get(&f.key).cloned().unwrap_or(Value::Null);
m.insert(f.key.clone(), coerce(f, &texts[i], &def));
}
m
};
assert_eq!(out["intensity"], Value::from(2.5));
assert_eq!(out["on"], Value::Bool(true));
assert_eq!(
out["color"],
serde_json::json!([1, 1, 1]),
"array untouched"
);
}
#[test]
fn assemble_preserves_authored_value_on_a_blank_edit() {
let fields = fields_for("PointLight", None);
let (idx, key) = fields
.iter()
.enumerate()
.find(|(_, f)| matches!(f.kind, FieldKind::Float))
.map(|(i, f)| (i, f.key.clone()))
.expect("a float field");
let mut authored = base_args("PointLight");
authored.insert(key.clone(), Value::from(999.0));
let mut texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
texts[idx] = String::new();
let out = assemble("PointLight", Some(&authored), &fields, &texts);
assert_eq!(
out[&key],
Value::from(999.0),
"a blank numeric edit keeps the authored value, not the type default"
);
}
#[test]
fn coerce_falls_back_to_default_on_bad_numbers() {
let f = FormField {
key: "n".into(),
kind: FieldKind::Int,
initial: String::new(),
boolval: false,
variants: Vec::new(),
variant_idx: 0,
};
assert_eq!(coerce(&f, "abc", &Value::from(7)), Value::from(7));
assert_eq!(coerce(&f, "42", &Value::from(7)), Value::from(42));
}
#[test]
fn assemble_then_validate_round_trips_a_real_type() {
let fields = fields_for("PointLight", None);
let texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
let args = assemble("PointLight", None, &fields, &texts);
assert!(
validate("PointLight", "probe", &args).is_ok(),
"the default-derived args re-serialize cleanly"
);
}
#[test]
fn every_add_type_form_round_trips_its_defaults() {
for ty in crate::editor::panel::picker_types() {
let fields = fields_for(ty, None);
let texts: Vec<String> = fields.iter().map(|f| f.initial.clone()).collect();
let args = assemble(ty, None, &fields, &texts);
assert!(
validate(ty, "probe", &args).is_ok(),
"{ty}: the default-derived form must re-validate"
);
}
}
#[test]
fn hitregion_form_offers_scalars_refs_and_skips_undeclared_nulls() {
let fields = fields_for("HitRegion", None);
let field = |k: &str| fields.iter().find(|f| f.key == k).map(|f| f.kind);
assert_eq!(field("x"), Some(FieldKind::Float));
assert_eq!(field("width"), Some(FieldKind::Float));
assert_eq!(field("action"), Some(FieldKind::Str));
assert_eq!(field("disabled"), Some(FieldKind::Bool));
assert_eq!(
field("label"),
Some(FieldKind::Ref {
target: "TextLabel"
})
);
assert_eq!(field("screen"), Some(FieldKind::Ref { target: "Screen" }));
for skipped in ["hover_color", "hover_scale", "drag_handle"] {
assert!(
field(skipped).is_none(),
"{skipped} (an undeclared null Option) is not an editable field"
);
}
}
#[test]
fn validate_rejects_an_out_of_range_value() {
let mut args = base_args("PointLight");
if let Some((k, _)) = args
.clone()
.iter()
.find(|(_, v)| v.is_u64() || (v.is_i64() && v.as_i64() == Some(0)))
{
args.insert(k.clone(), Value::from(-1));
if base_args("PointLight")[k].is_u64() {
assert!(validate("PointLight", "probe", &args).is_err());
}
}
assert!(validate("PointLight", "probe", &base_args("PointLight")).is_ok());
}
}