Something went wrong. Try again.
atproto Thingiverse but good
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825//! 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<String>, 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<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, /// Thing-level compatibility links, surfaced as resolvable "Works with" chips. #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>, #[serde(default)] pub models: Vec<DraftModelInput>,}
/// 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<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub summary: Option<String>, /// Per-model license override. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub tags: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub instructions: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub cover: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>, #[serde(default)] pub parts: Vec<DraftPartInput>,}
/// 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<String>, /// 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<String>, /// Inline file manifest (alternative to `upload_id`). #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option<File>, #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub notes: Option<String>, /// Unit label for this part's dimensions (distinct from `dimensions.unit`). #[serde(default, skip_serializing_if = "Option::is_none")] pub units: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] pub print_settings: Option<Vec<String>>, #[serde(default, skip_serializing_if = "Option::is_none")] pub dimensions: Option<Bbox>, #[serde(default, skip_serializing_if = "Option::is_none")] pub previews: Option<Vec<Image>>, /// Remix provenance; design pending. Carried pass-through, not yet surfaced. #[serde(default, skip_serializing_if = "Option::is_none")] pub derived_from: Option<StrongRef>, /// Part-level compatibility links; not yet surfaced (thing-level only). #[serde(default, skip_serializing_if = "Option::is_none")] pub intended_to_work_with: Option<Vec<StrongRef>>,}
/// 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<ValidationIssue>,}
impl ValidationErrors { fn push(&mut self, field: impl Into<String>, message: impl Into<String>) { 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<String>) -> 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<String>) -> Option<S> { value .as_deref() .map(str::trim) .filter(|v| !v.is_empty()) .map(S::from)}
fn opt_vec_s(value: &Option<Vec<String>>) -> Option<Vec<S>> { value .as_ref() .map(|items| items.iter().map(|s| S::from(s.trim())).collect::<Vec<_>>())}
/// 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<Vec<String>>) -> Option<Vec<S>> { let lines: Vec<S> = 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<ThingInput<S>, 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::<Vec<_>>();
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<Bbox>) -> Option<Bbox> { 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<AtUri, String> { 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()); }}