//! Publish-draft data model. //! //! A draft is a server-side, non-public, in-progress publish composition. It is //! persisted as a [`DraftThingInput`]: a mirror of the generated //! [`ThingInput`](polymodel_api::space_polymodel::library::publish_thing::ThingInput) //! with every field optional, so a partial draft (saved before a name, license, //! or any model exists) round-trips losslessly. The same camelCase field names //! keep the payload shape aligned with the publishable record. //! //! Leaf media/reference types (`Image`, `File`, `Bbox`, `StrongRef`) are reused //! from the generated API at their default owned-string type, so an uploaded //! image/file ref stored in a draft is byte-identical to what `publishThing` //! consumes. //! //! [`DraftThingInput::assemble`] is the single conversion point from a draft to //! a publishable `ThingInput`: it validates required structure (which is //! enforced *structurally* by the typed record — `name`/`license`/`models`, //! `name`/`parts`, `name`+`file` are not optional there) and then defers the //! length/grapheme limits to the generated [`LexiconSchema::validate`], so draft //! validation cannot drift from the lexicon. use jacquard::common::types::string::AtUri; use jacquard_common::DefaultStr; use jacquard_lexicon::schema::LexiconSchema; use polymodel_api::com_atproto::repo::strong_ref::StrongRef; use crate::thing_detail::thing_uri_from_route; use polymodel_api::space_polymodel::library::publish_thing::{ModelInput, PartInput, ThingInput}; use polymodel_api::space_polymodel::library::{File, Image}; use serde::{Deserialize, Serialize}; // Re-export leaf types the publish UI edits directly. pub use polymodel_api::space_polymodel::library::Bbox; /// The owned string type the generated records use (`DefaultStr`). type S = DefaultStr; // ---------------------------------------------------------------------------- // Draft endpoint wire types // // Defined here (cross-target) rather than in the server-only `appview::drafts` // module so the wasm client can share them with the server. // ---------------------------------------------------------------------------- /// Response for create/upsert: the opaque draft id the client should keep. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DraftIdResponse { pub draft_id: String, } /// A draft listing row, rendered from denormalized columns (no payload parse). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DraftSummary { pub draft_id: String, pub name: Option, pub model_count: i64, pub created_at: i64, pub updated_at: i64, } /// Response for delete: whether a row actually existed. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DeleteDraftResponse { pub deleted: bool, } /// Response for image upload: a fully-formed `#image` ready to attach. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct ImageUploadResponse { pub image: Image, } /// A `ThingInput`-shaped draft payload with every field optional. #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftThingInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option, /// Thing-level compatibility links, surfaced as resolvable "Works with" chips. #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option>, #[serde(default)] pub models: Vec, } /// A model within a draft. `parts` are ordered; an empty draft model is allowed /// (it simply fails publish validation until it has a name and at least one part). #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftModelInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option, /// Per-model license override. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option>, #[serde(default)] pub parts: Vec, } /// A part within a draft model. A part is publishable once it has a name and a /// successfully-staged file (an inline `file` manifest or a stage `upload_id`). #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)] #[serde(rename_all = "camelCase")] pub struct DraftPartInput { #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, /// stageFile upload id; the server resolves it to a `File` manifest at publish. #[serde(default, skip_serializing_if = "Option::is_none")] pub upload_id: Option, /// Inline file manifest (alternative to `upload_id`). #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option, /// Unit label for this part's dimensions (distinct from `dimensions.unit`). #[serde(default, skip_serializing_if = "Option::is_none")] pub units: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub print_settings: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub dimensions: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option, /// Part-level compatibility links; not yet surfaced (thing-level only). #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option>, } /// One actionable validation problem, addressed to a draft field path. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidationIssue { pub field: String, pub message: String, } /// All validation problems found while assembling a draft into a `ThingInput`. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct ValidationErrors { pub issues: Vec, } impl ValidationErrors { fn push(&mut self, field: impl Into, message: impl Into) { self.issues.push(ValidationIssue { field: field.into(), message: message.into(), }); } pub fn is_empty(&self) -> bool { self.issues.is_empty() } /// Whether any issue targets the given field path exactly. pub fn has_field(&self, field: &str) -> bool { self.issues.iter().any(|i| i.field == field) } } impl std::fmt::Display for ValidationErrors { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut first = true; for issue in &self.issues { if !first { write!(f, "; ")?; } write!(f, "{}: {}", issue.field, issue.message)?; first = false; } Ok(()) } } fn nonempty(value: &Option) -> bool { value .as_deref() .map(|v| !v.trim().is_empty()) .unwrap_or(false) } fn into_s(value: &str) -> S { S::from(value.trim()) } fn opt_s(value: &Option) -> Option { value .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .map(S::from) } fn opt_vec_s(value: &Option>) -> Option> { value .as_ref() .map(|items| items.iter().map(|s| S::from(s.trim())).collect::>()) } /// Convert a freeform multi-line text field (stored raw, one entry per line) /// into a list of non-empty steps. Whitespace-only lines are dropped here, at /// assembly, so the live textarea can keep spaces and blank lines while typing. fn clean_lines(value: &Option>) -> Option> { let lines: Vec = value .as_ref()? .iter() .map(|line| line.trim_end()) .filter(|line| !line.trim().is_empty()) .map(S::from) .collect(); if lines.is_empty() { None } else { Some(lines) } } impl DraftPartInput { /// A part is backed by a staged file when it has either an inline manifest /// or a non-empty upload id. pub fn has_file(&self) -> bool { self.file.is_some() || nonempty(&self.upload_id) } /// A part is publishable when it has a non-empty name and a staged file. #[allow(dead_code)] pub fn is_publishable(&self) -> bool { nonempty(&self.name) && self.has_file() } } impl DraftModelInput { /// A model is publishable when it has a non-empty name and at least one /// publishable part. #[allow(dead_code)] pub fn is_publishable(&self) -> bool { nonempty(&self.name) && !self.parts.is_empty() && self.parts.iter().all(|p| p.is_publishable()) } } impl DraftThingInput { /// Display name for draft listings, if set. pub fn display_name(&self) -> Option<&str> { self.name .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) } /// Number of models (for the denormalized `model_count` listing column). pub fn model_count(&self) -> i64 { self.models.len() as i64 } /// Basics is complete once the thing has a name. License has an effective /// default and is chosen in the details step, so it never gates Basics. #[allow(dead_code)] pub fn basics_complete(&self) -> bool { nonempty(&self.name) } /// Step 2 (Files & parts) is complete when there is at least one model and /// every model is publishable. #[allow(dead_code)] pub fn files_complete(&self) -> bool { !self.models.is_empty() && self.models.iter().all(|m| m.is_publishable()) } /// Validate the draft and convert it into a publishable `ThingInput`. /// /// Required structure is checked here (the draft is all-optional); the /// length/grapheme limits are delegated to the generated /// [`LexiconSchema::validate`] on the assembled records so this code never /// re-declares the lexicon's constraints. pub fn assemble(&self) -> Result, ValidationErrors> { let mut errors = ValidationErrors::default(); if !nonempty(&self.name) { errors.push("name", "A name is required to publish."); } if self.models.is_empty() { errors.push("models", "Add at least one model with a file."); } for (mi, model) in self.models.iter().enumerate() { if !nonempty(&model.name) { errors.push(format!("models[{mi}].name"), "Each model needs a name."); } if model.parts.is_empty() { errors.push( format!("models[{mi}].parts"), "Each model needs at least one part with a file.", ); } for (pi, part) in model.parts.iter().enumerate() { if !nonempty(&part.name) { errors.push( format!("models[{mi}].parts[{pi}].name"), "Each part needs a name.", ); } if !part.has_file() { errors.push( format!("models[{mi}].parts[{pi}].file"), "Upload a file for this part.", ); } // Dimensions are all-or-none: if any axis is set, all three // must be non-empty. The lexicon only requires the fields exist, // so this presence check is enforced here, not by the validator. if let Some(bbox) = &part.dimensions { let axes = [bbox.x.as_str(), bbox.y.as_str(), bbox.z.as_str()]; let set_count = axes.iter().filter(|a| !a.trim().is_empty()).count(); if set_count != 0 && set_count != 3 { errors.push( format!("models[{mi}].parts[{pi}].dimensions"), "Set all three dimensions (X, Y, Z), or none.", ); } } } } if !errors.is_empty() { return Err(errors); } // Presence is satisfied above; this pass only constructs typed records. let models = self .models .iter() .map(|model| { let parts = model .parts .iter() .map(|part| { PartInput::builder() .name(into_s(part.name.as_deref().unwrap_or_default())) .upload_id(opt_s(&part.upload_id)) .file(part.file.clone()) .format(opt_s(&part.format)) .notes(opt_s(&part.notes)) .units(opt_s(&part.units)) .print_settings(opt_vec_s(&part.print_settings)) .dimensions(clean_bbox(part.dimensions.clone())) .previews(part.previews.clone()) .derived_from(part.derived_from.clone()) .intended_to_work_with(part.intended_to_work_with.clone()) .build() }) .collect(); ModelInput { cover: model.cover.clone(), derived_from: model.derived_from.clone(), instructions: opt_vec_s(&model.instructions), intended_to_work_with: model.intended_to_work_with.clone(), license: opt_s(&model.license), name: into_s(model.name.as_deref().unwrap_or_default()), parts, previews: model.previews.clone(), summary: opt_s(&model.summary), tags: opt_vec_s(&model.tags), extra_data: None, } }) .collect::>(); let thing = ThingInput { cover: self.cover.clone(), derived_from: self.derived_from.clone(), instructions: clean_lines(&self.instructions), intended_to_work_with: self.intended_to_work_with.clone(), license: into_s( self.license .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .unwrap_or(super::DEFAULT_LICENSE), ), models, name: into_s(self.name.as_deref().unwrap_or_default()), previews: self.previews.clone(), summary: opt_s(&self.summary), tags: opt_vec_s(&self.tags), extra_data: None, }; // Defer length/grapheme limits to the generated lexicon validators so // this code never duplicates (and cannot drift from) the constraints. if let Err(err) = thing.validate() { errors.push("name", lexicon_message(&err)); } for (mi, model) in thing.models.iter().enumerate() { if let Err(err) = model.validate() { errors.push(format!("models[{mi}]"), lexicon_message(&err)); } for (pi, part) in model.parts.iter().enumerate() { if let Err(err) = part.validate() { errors.push(format!("models[{mi}].parts[{pi}]"), lexicon_message(&err)); } } } if errors.is_empty() { Ok(thing) } else { Err(errors) } } } fn lexicon_message(err: &jacquard_lexicon::validation::ConstraintError) -> String { format!("Exceeds a lexicon limit: {err}") } /// Drop a `Bbox` whose axes are all empty so an in-progress edit does not /// serialize as a partial/empty dimensions object. Unit alone is not a dimension. fn clean_bbox(bbox: Option) -> Option { bbox.and_then(|b| { let empty = b.x.as_str().trim().is_empty() && b.y.as_str().trim().is_empty() && b.z.as_str().trim().is_empty(); if empty { None } else { Some(b) } }) } /// Derive a thing/model name from an uploaded filename: drop the extension and /// trim surrounding whitespace. A bare extension (e.g. `.stl`) or extensionless /// name is returned trimmed. pub fn name_from_filename(filename: &str) -> String { let trimmed = filename.trim(); match trimmed.rsplit_once('.') { Some((stem, _ext)) if !stem.trim().is_empty() => stem.trim().to_string(), _ => trimmed.to_string(), } } /// Give a freshly-uploaded-into draft the structure `assemble()` requires: a /// thing name derived from the first filename (only when currently unset) and at /// least one model carrying a non-empty name. Pure and idempotent — a later drop /// neither renames the thing nor adds a second default model. pub fn ensure_default_model_for_upload(thing: &mut DraftThingInput, first_filename: &str) { let derived = name_from_filename(first_filename); if !nonempty(&thing.name) && !derived.is_empty() { thing.name = Some(derived.clone()); } if thing.models.is_empty() { let model_name = thing .display_name() .map(str::to_string) .filter(|name| !name.is_empty()) .or_else(|| (!derived.is_empty()).then(|| derived.clone())) .unwrap_or_else(|| "Model 1".to_string()); thing.models.push(DraftModelInput { name: Some(model_name), ..Default::default() }); } } /// Parse one "works with" token — an `at://` URI, a full Polymodel thing URL, or /// a bare `/{repo}/thing/{rkey}` route path — into a thing `AtUri`. Pure; the /// network resolution to a `StrongRef` happens separately. Query strings and /// fragments are ignored; anything that is not a thing link is rejected. pub fn parse_thing_token(token: &str) -> Result { let token = token.trim(); if token.is_empty() { return Err("Empty link.".into()); } if token.starts_with("at://") { let uri = AtUri::new_owned(token).map_err(|e| format!("Invalid at:// URI: {e}"))?; // Only thing-record URIs are valid "Works with" links; reject model/part // or other NSIDs early with a clear message rather than a decode error. let is_thing = uri .path() .map(|p| p.collection.as_str() == "space.polymodel.library.thing") .unwrap_or(false); if !is_thing { return Err("Not a thing link.".to_string()); } return Ok(uri); } let rest = match token.split_once("://") { Some((_scheme, rest)) => rest.split_once('/').map(|(_host, path)| path).unwrap_or(""), None => token, }; let path = rest .split(['?', '#']) .next() .unwrap_or("") .trim_matches('/'); let segments: Vec<&str> = path.split('/').collect(); match segments.as_slice() { [repo, "thing", rkey] if !repo.is_empty() && !rkey.is_empty() => { thing_uri_from_route(repo, rkey) } _ => Err("Not a Polymodel thing link.".to_string()), } } #[cfg(test)] mod tests { use super::*; fn part_with_upload(name: &str) -> DraftPartInput { DraftPartInput { name: Some(name.to_string()), upload_id: Some("abcdef0123456789-42".to_string()), ..Default::default() } } fn complete_draft() -> DraftThingInput { DraftThingInput { name: Some("Desk Organizer".to_string()), license: Some("CC-BY-4.0".to_string()), summary: Some("A tidy desk caddy.".to_string()), models: vec![DraftModelInput { name: Some("Tray".to_string()), parts: vec![part_with_upload("tray.stl")], ..Default::default() }], ..Default::default() } } #[test] fn partial_draft_round_trips_through_json() { let draft = DraftThingInput { name: Some("WIP".to_string()), ..Default::default() }; let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); // A bare partial draft must not serialize required-but-absent fields. assert!(!json.contains("license")); assert!(json.contains("\"name\":\"WIP\"")); } #[test] fn empty_draft_round_trips_and_is_not_publishable() { let draft = DraftThingInput::default(); let json = serde_json::to_string(&draft).unwrap(); let restored: DraftThingInput = serde_json::from_str(&json).unwrap(); assert_eq!(draft, restored); assert!(!draft.basics_complete()); assert!(!draft.files_complete()); assert!(draft.assemble().is_err()); } #[test] fn complete_draft_assembles_to_valid_thing_input() { let draft = complete_draft(); assert!(draft.basics_complete()); assert!(draft.files_complete()); let thing = draft.assemble().expect("complete draft assembles"); assert_eq!(thing.name.as_str(), "Desk Organizer"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); assert_eq!(thing.models.len(), 1); assert_eq!(thing.models[0].name.as_str(), "Tray"); assert_eq!(thing.models[0].parts.len(), 1); assert_eq!(thing.models[0].parts[0].name.as_str(), "tray.stl"); // The assembled record passes the generated lexicon validators. assert!(thing.validate().is_ok()); } #[test] fn missing_thing_fields_are_reported() { let draft = DraftThingInput::default(); let errors = draft.assemble().expect_err("empty draft fails"); assert!(errors.has_field("name")); assert!(errors.has_field("models")); // License is not required: it defaults at assembly, never an error. assert!(!errors.has_field("license")); } #[test] fn unset_license_defaults_at_assembly() { let mut draft = complete_draft(); draft.license = None; let thing = draft.assemble().expect("unset license defaults"); assert_eq!(thing.license.as_str(), "CC-BY-4.0"); } #[test] fn missing_model_name_and_parts_are_reported() { let mut draft = complete_draft(); draft.models = vec![DraftModelInput::default()]; let errors = draft .assemble() .expect_err("model with no name/parts fails"); assert!(errors.has_field("models[0].name")); assert!(errors.has_field("models[0].parts")); } #[test] fn part_without_file_is_reported() { let mut draft = complete_draft(); draft.models[0].parts = vec![DraftPartInput { name: Some("loose.stl".to_string()), ..Default::default() }]; let errors = draft.assemble().expect_err("part with no file fails"); assert!(errors.has_field("models[0].parts[0].file")); assert!(!errors.has_field("models[0].parts[0].name")); } #[test] fn shared_name_limit_accepts_long_model_name() { let name = "1 x 5 Tile with Groove without Understud with Track (6mm) (Lego-compatible)"; let mut draft = complete_draft(); draft.name = Some(name.to_string()); draft.models[0].name = Some(name.to_string()); draft.models[0].parts[0].name = Some(name.to_string()); draft.models[0].tags = Some(vec!["a".repeat(128)]); draft .assemble() .expect("shared name and tag limits accept valid values"); } #[test] fn over_long_name_trips_lexicon_validator() { let mut draft = complete_draft(); draft.name = Some("x".repeat(257)); let errors = draft .assemble() .expect_err("over-long name fails lexicon validate"); assert!(errors.has_field("name")); } #[test] fn whitespace_only_name_is_not_complete() { let mut draft = complete_draft(); draft.name = Some(" ".to_string()); assert!(!draft.basics_complete()); assert!(draft.assemble().is_err()); } #[test] fn name_from_filename_strips_extension_and_trims() { assert_eq!( name_from_filename(" Desk Organizer.stl "), "Desk Organizer" ); assert_eq!(name_from_filename("a.b.glb"), "a.b"); assert_eq!(name_from_filename("noext"), "noext"); // A bare dotfile keeps its name rather than collapsing to empty. assert_eq!(name_from_filename(".stl"), ".stl"); } #[test] fn ensure_default_model_makes_a_one_file_drop_assemblable() { let mut draft = DraftThingInput::default(); ensure_default_model_for_upload(&mut draft, "Bracket.stl"); draft.models[0].parts.push(part_with_upload("Bracket.stl")); assert_eq!(draft.name.as_deref(), Some("Bracket")); assert_eq!(draft.models.len(), 1); assert!(nonempty(&draft.models[0].name)); assert!(draft.assemble().is_ok(), "auto-created draft must publish"); } #[test] fn ensure_default_model_does_not_overwrite_name_or_add_second_model() { let mut draft = DraftThingInput { name: Some("Chosen".to_string()), ..Default::default() }; ensure_default_model_for_upload(&mut draft, "First.stl"); ensure_default_model_for_upload(&mut draft, "Second.stl"); assert_eq!(draft.name.as_deref(), Some("Chosen")); assert_eq!(draft.models.len(), 1); } #[test] fn parse_thing_token_accepts_at_uri_url_and_path() { let at = parse_thing_token("at://did:plc:abc123/space.polymodel.library.thing/rkey1") .expect("at:// passes through"); assert_eq!(at.authority().as_str(), "did:plc:abc123"); let url = parse_thing_token("https://polymodel.app/alice.test/thing/rkey2") .expect("full URL resolves"); assert_eq!(url.authority().as_str(), "alice.test"); assert_eq!( url.path() .and_then(|p| p.rkey) .map(|r| r.as_ref().to_string()), Some("rkey2".to_string()) ); let path = parse_thing_token("/alice.test/thing/rkey3").expect("bare path resolves"); assert_eq!(path.authority().as_str(), "alice.test"); // Query strings and fragments are ignored. let q = parse_thing_token("https://host/alice.test/thing/rkey4?ref=x#frag") .expect("query/fragment stripped"); assert_eq!( q.path() .and_then(|p| p.rkey) .map(|r| r.as_ref().to_string()), Some("rkey4".to_string()) ); } #[test] fn parse_thing_token_rejects_non_thing_links() { assert!(parse_thing_token("").is_err()); assert!(parse_thing_token("not a link").is_err()); assert!(parse_thing_token("https://host/alice.test/profile").is_err()); assert!(parse_thing_token("at://garbage").is_err()); } #[test] fn per_model_and_per_part_fields_map_through_assemble() { let draft = DraftThingInput { name: Some("Rig".to_string()), models: vec![DraftModelInput { name: Some("Frame".to_string()), summary: Some("Main frame".to_string()), license: Some("MIT".to_string()), tags: Some(vec!["frame".to_string()]), instructions: Some(vec!["Print at 0.2mm".to_string()]), parts: vec![DraftPartInput { name: Some("frame.stl".to_string()), upload_id: Some("abcdef0123456789-1".to_string()), notes: Some("Supports off".to_string()), units: Some("mm".to_string()), print_settings: Some(vec!["0.2mm layers".to_string()]), dimensions: Some(Bbox { unit: Some("mm".into()), x: "10".into(), y: "20".into(), z: "30".into(), extra_data: None, }), ..Default::default() }], ..Default::default() }], ..Default::default() }; let thing = draft.assemble().expect("rich draft assembles"); let model = &thing.models[0]; assert_eq!(model.summary.as_ref().unwrap().as_str(), "Main frame"); assert_eq!(model.license.as_ref().unwrap().as_str(), "MIT"); assert_eq!(model.tags.as_ref().unwrap()[0].as_str(), "frame"); let part = &model.parts[0]; assert_eq!(part.notes.as_ref().unwrap().as_str(), "Supports off"); assert_eq!(part.units.as_ref().unwrap().as_str(), "mm"); assert_eq!( part.print_settings.as_ref().unwrap()[0].as_str(), "0.2mm layers" ); let bbox = part.dimensions.as_ref().unwrap(); assert_eq!(bbox.x.as_str(), "10"); assert_eq!(bbox.unit.as_ref().unwrap().as_str(), "mm"); assert!(thing.validate().is_ok()); } #[test] fn partial_bbox_is_rejected_by_assemble() { let mut draft = complete_draft(); // Only X set → must fail validation (all-or-none). draft.models[0].parts[0].dimensions = Some(Bbox { unit: Some("mm".into()), x: "10".into(), y: "".into(), z: "".into(), extra_data: None, }); let errors = draft .assemble() .expect_err("partial bbox must fail assemble"); assert!(errors.has_field("models[0].parts[0].dimensions")); } #[test] fn empty_bbox_axes_are_dropped_at_assemble() { let mut draft = complete_draft(); // All axes empty (unit set) → dimensions dropped entirely, no error. draft.models[0].parts[0].dimensions = Some(Bbox { unit: Some("mm".into()), x: "".into(), y: "".into(), z: "".into(), extra_data: None, }); let thing = draft.assemble().expect("empty-axes bbox is dropped"); assert!(thing.models[0].parts[0].dimensions.is_none()); } }