1use serde::Serialize;
10use std::collections::BTreeSet;
11use std::io::{self, Write};
12
13use crate::{CollectionIdV1, CollectionLogicalIdV1, CollectionSourceKeyV1, InputIdentity};
14
15pub const TRANSITION_FAMILY_V1_ID: &str = "urn:animsmith:schema:transition-family:1";
17pub const TRANSITION_FAMILY_V1_SCHEMA_VERSION: u32 = 1;
19pub const TRANSITION_FAMILY_V1_MAX_SOURCE_BYTES: u64 = 8 * 1024 * 1024;
21pub const TRANSITION_FAMILY_V1_MAX_NORMALIZED_BYTES: u64 = 8 * 1024 * 1024;
23pub const TRANSITION_FAMILY_V1_MAX_DEPTH: usize = 16;
25pub const TRANSITION_FAMILY_V1_MAX_FAMILIES: usize = 4_096;
27pub const TRANSITION_FAMILY_V1_MAX_MEMBERS_PER_FAMILY: usize = 4_096;
29pub const TRANSITION_FAMILY_V1_MAX_AGGREGATE_MEMBERS: usize = 16_384;
31pub const TRANSITION_FAMILY_V1_MAX_STRING_BYTES: usize = 4_096;
33pub const TRANSITION_FAMILY_V1_MAX_DOCUMENT_FAMILY_ID_BYTES: usize = 255;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
38#[serde(rename_all = "kebab-case")]
39pub enum TransitionFamilyBoundaryV1 {
40 Entry,
42 Exit,
44 Both,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
50pub struct TransitionFamilyBasisV1 {
51 translation: &'static str,
52 rotation: &'static str,
53 time: &'static str,
54}
55
56impl TransitionFamilyBasisV1 {
57 #[must_use]
59 pub const fn skeleton_local() -> Self {
60 Self {
61 translation: "skeleton-local-metres",
62 rotation: "skeleton-local-degrees",
63 time: "normalized-clip",
64 }
65 }
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
70pub struct TransitionFamilyTolerancesV1 {
71 translation_m: f64,
72 rotation_deg: f64,
73 time_normalized: f64,
74}
75
76impl TransitionFamilyTolerancesV1 {
77 pub fn new(
79 translation_m: f64,
80 rotation_deg: f64,
81 time_normalized: f64,
82 ) -> Result<Self, TransitionFamilyError> {
83 for (field, value) in [
84 ("translation_m", translation_m),
85 ("rotation_deg", rotation_deg),
86 ("time_normalized", time_normalized),
87 ] {
88 if !value.is_finite() || value < 0.0 {
89 return Err(TransitionFamilyError::InvalidTolerance { field });
90 }
91 }
92 Ok(Self {
93 translation_m,
94 rotation_deg,
95 time_normalized,
96 })
97 }
98
99 pub const fn translation_m(self) -> f64 {
101 self.translation_m
102 }
103 pub const fn rotation_deg(self) -> f64 {
105 self.rotation_deg
106 }
107 pub const fn time_normalized(self) -> f64 {
109 self.time_normalized
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct DocumentTransitionFamilyMemberV1 {
116 take_index: u64,
117 take_name: String,
118}
119
120impl DocumentTransitionFamilyMemberV1 {
121 pub fn new(take_index: u64, take_name: String) -> Result<Self, TransitionFamilyError> {
123 validate_text("take_name", &take_name, false)?;
124 Ok(Self {
125 take_index,
126 take_name,
127 })
128 }
129 pub const fn take_index(&self) -> u64 {
131 self.take_index
132 }
133 pub fn take_name(&self) -> &str {
135 &self.take_name
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141pub struct CollectionTransitionFamilyMemberV1 {
142 logical_id: CollectionLogicalIdV1,
143 source: CollectionSourceKeyV1,
144 take_index: u64,
145 take_name: String,
146}
147
148impl CollectionTransitionFamilyMemberV1 {
149 pub fn new(
151 logical_id: CollectionLogicalIdV1,
152 source: CollectionSourceKeyV1,
153 take_index: u64,
154 take_name: String,
155 ) -> Result<Self, TransitionFamilyError> {
156 validate_text("take_name", &take_name, false)?;
157 Ok(Self {
158 logical_id,
159 source,
160 take_index,
161 take_name,
162 })
163 }
164 pub fn logical_id(&self) -> &CollectionLogicalIdV1 {
166 &self.logical_id
167 }
168 pub fn source(&self) -> &CollectionSourceKeyV1 {
170 &self.source
171 }
172 pub const fn take_index(&self) -> u64 {
174 self.take_index
175 }
176 pub fn take_name(&self) -> &str {
178 &self.take_name
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize)]
184pub struct DocumentTransitionFamilyV1 {
185 family_id: String,
186 boundary: TransitionFamilyBoundaryV1,
187 basis: TransitionFamilyBasisV1,
188 tolerances: TransitionFamilyTolerancesV1,
189 members: Vec<DocumentTransitionFamilyMemberV1>,
190}
191
192impl DocumentTransitionFamilyV1 {
193 pub fn new(
195 family_id: String,
196 boundary: TransitionFamilyBoundaryV1,
197 tolerances: TransitionFamilyTolerancesV1,
198 members: Vec<DocumentTransitionFamilyMemberV1>,
199 ) -> Result<Self, TransitionFamilyError> {
200 validate_document_family_id(&family_id)?;
201 validate_document_members(&members)?;
202 Ok(Self {
203 family_id,
204 boundary,
205 basis: TransitionFamilyBasisV1::skeleton_local(),
206 tolerances,
207 members,
208 })
209 }
210 pub fn family_id(&self) -> &str {
212 &self.family_id
213 }
214 pub const fn boundary(&self) -> TransitionFamilyBoundaryV1 {
216 self.boundary
217 }
218 pub const fn basis(&self) -> TransitionFamilyBasisV1 {
220 self.basis
221 }
222 pub const fn tolerances(&self) -> TransitionFamilyTolerancesV1 {
224 self.tolerances
225 }
226 pub fn members(&self) -> &[DocumentTransitionFamilyMemberV1] {
228 &self.members
229 }
230}
231
232#[derive(Debug, Clone, PartialEq, Serialize)]
234pub struct CollectionTransitionFamilyV1 {
235 family_id: CollectionLogicalIdV1,
236 boundary: TransitionFamilyBoundaryV1,
237 basis: TransitionFamilyBasisV1,
238 tolerances: TransitionFamilyTolerancesV1,
239 members: Vec<CollectionTransitionFamilyMemberV1>,
240}
241
242impl CollectionTransitionFamilyV1 {
243 pub fn new(
245 family_id: CollectionLogicalIdV1,
246 boundary: TransitionFamilyBoundaryV1,
247 tolerances: TransitionFamilyTolerancesV1,
248 members: Vec<CollectionTransitionFamilyMemberV1>,
249 ) -> Result<Self, TransitionFamilyError> {
250 validate_collection_members(&members)?;
251 Ok(Self {
252 family_id,
253 boundary,
254 basis: TransitionFamilyBasisV1::skeleton_local(),
255 tolerances,
256 members,
257 })
258 }
259 pub fn family_id(&self) -> &CollectionLogicalIdV1 {
261 &self.family_id
262 }
263 pub const fn boundary(&self) -> TransitionFamilyBoundaryV1 {
265 self.boundary
266 }
267 pub const fn basis(&self) -> TransitionFamilyBasisV1 {
269 self.basis
270 }
271 pub const fn tolerances(&self) -> TransitionFamilyTolerancesV1 {
273 self.tolerances
274 }
275 pub fn members(&self) -> &[CollectionTransitionFamilyMemberV1] {
277 &self.members
278 }
279}
280
281#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
283pub struct TransitionFamilyManifestIdentityV1 {
284 collection_id: CollectionIdV1,
285 #[serde(rename = "manifest_input_identity")]
286 input: InputIdentity,
287}
288
289impl TransitionFamilyManifestIdentityV1 {
290 pub fn new(
292 collection_id: CollectionIdV1,
293 input: InputIdentity,
294 ) -> Result<Self, TransitionFamilyError> {
295 if input.bytes() > crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES {
296 return Err(TransitionFamilyError::ManifestTooLarge);
297 }
298 Ok(Self {
299 collection_id,
300 input,
301 })
302 }
303 pub fn collection_id(&self) -> &CollectionIdV1 {
305 &self.collection_id
306 }
307 pub const fn input(&self) -> &InputIdentity {
309 &self.input
310 }
311}
312
313#[derive(Debug, Clone, PartialEq, Serialize)]
315#[serde(tag = "scope", rename_all = "kebab-case")]
316#[non_exhaustive]
317pub enum TransitionFamilyDeclarationV1 {
318 Document {
320 #[serde(skip)]
322 schema: &'static str,
323 #[serde(skip)]
325 schema_version: u32,
326 families: Vec<DocumentTransitionFamilyV1>,
328 },
329 Collection {
331 #[serde(skip)]
333 schema: &'static str,
334 #[serde(skip)]
336 schema_version: u32,
337 #[serde(flatten)]
339 manifest: TransitionFamilyManifestIdentityV1,
340 families: Vec<CollectionTransitionFamilyV1>,
342 },
343}
344
345impl TransitionFamilyDeclarationV1 {
346 pub fn document(
348 mut families: Vec<DocumentTransitionFamilyV1>,
349 ) -> Result<Self, TransitionFamilyError> {
350 validate_family_count(families.len())?;
351 families.sort_by(|left, right| left.family_id.cmp(&right.family_id));
352 if families
353 .windows(2)
354 .any(|pair| pair[0].family_id == pair[1].family_id)
355 {
356 return Err(TransitionFamilyError::DuplicateFamily);
357 }
358 validate_aggregate_document(&families)?;
359 Ok(Self::Document {
360 schema: TRANSITION_FAMILY_V1_ID,
361 schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
362 families,
363 })
364 }
365
366 pub fn collection(
368 manifest: TransitionFamilyManifestIdentityV1,
369 mut families: Vec<CollectionTransitionFamilyV1>,
370 ) -> Result<Self, TransitionFamilyError> {
371 if families.is_empty() {
372 return Err(TransitionFamilyError::EmptyCollection);
373 }
374 validate_family_count(families.len())?;
375 let owner_prefix = format!("{}/", manifest.collection_id.as_str());
376 if families
377 .iter()
378 .any(|family| !family.family_id.as_str().starts_with(&owner_prefix))
379 {
380 return Err(TransitionFamilyError::CollectionFamilyOutsideOwner);
381 }
382 if families
383 .iter()
384 .flat_map(|family| family.members.iter())
385 .any(|member| !member.logical_id.as_str().starts_with(&owner_prefix))
386 {
387 return Err(TransitionFamilyError::CollectionMemberOutsideOwner);
388 }
389 families.sort_by(|left, right| left.family_id.cmp(&right.family_id));
390 if families
391 .windows(2)
392 .any(|pair| pair[0].family_id == pair[1].family_id)
393 {
394 return Err(TransitionFamilyError::DuplicateFamily);
395 }
396 validate_aggregate_collection(&families)?;
397 Ok(Self::Collection {
398 schema: TRANSITION_FAMILY_V1_ID,
399 schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
400 manifest,
401 families,
402 })
403 }
404
405 pub fn normalized_jcs(&self) -> Result<Vec<u8>, TransitionFamilyError> {
407 self.clone()
411 .canonicalize()?
412 .normalized_jcs_bounded(TRANSITION_FAMILY_V1_MAX_NORMALIZED_BYTES as usize)
413 }
414
415 pub fn document_families(&self) -> Option<&[DocumentTransitionFamilyV1]> {
417 match self {
418 Self::Document { families, .. } => Some(families),
419 Self::Collection { .. } => None,
420 }
421 }
422
423 pub fn collection_families(&self) -> Option<&[CollectionTransitionFamilyV1]> {
425 match self {
426 Self::Document { .. } => None,
427 Self::Collection { families, .. } => Some(families),
428 }
429 }
430
431 fn normalized_jcs_bounded(&self, maximum: usize) -> Result<Vec<u8>, TransitionFamilyError> {
432 let mut output = BoundedWriter::new(maximum);
433 let wire = match self {
434 Self::Document { families, .. } => TransitionFamilyNormalizedWire::Document {
435 schema: TRANSITION_FAMILY_V1_ID,
436 schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
437 scope: "document",
438 families,
439 },
440 Self::Collection {
441 manifest, families, ..
442 } => TransitionFamilyNormalizedWire::Collection {
443 schema: TRANSITION_FAMILY_V1_ID,
444 schema_version: TRANSITION_FAMILY_V1_SCHEMA_VERSION,
445 scope: "collection",
446 collection_id: manifest.collection_id(),
447 manifest_input_identity: manifest.input(),
448 families,
449 },
450 };
451 serde_jcs::to_writer(&mut output, &wire)
452 .map_err(|_| TransitionFamilyError::NormalizedTooLarge)?;
453 Ok(output.into_inner())
454 }
455
456 fn canonicalize(self) -> Result<Self, TransitionFamilyError> {
457 match self {
458 Self::Document { families, .. } => Self::document(families),
459 Self::Collection {
460 manifest, families, ..
461 } => Self::collection(manifest, families),
462 }
463 }
464}
465
466#[derive(Serialize)]
467#[serde(untagged)]
468enum TransitionFamilyNormalizedWire<'a> {
469 Document {
470 schema: &'static str,
471 schema_version: u32,
472 scope: &'static str,
473 families: &'a [DocumentTransitionFamilyV1],
474 },
475 Collection {
476 schema: &'static str,
477 schema_version: u32,
478 scope: &'static str,
479 collection_id: &'a CollectionIdV1,
480 manifest_input_identity: &'a InputIdentity,
481 families: &'a [CollectionTransitionFamilyV1],
482 },
483}
484
485#[derive(Debug, Clone, PartialEq)]
487pub struct TransitionFamilyDeclarationInputV1 {
488 declaration: TransitionFamilyDeclarationV1,
489 source_identity: InputIdentity,
490 normalized_identity: InputIdentity,
491}
492
493impl TransitionFamilyDeclarationInputV1 {
494 pub fn new(
496 declaration: TransitionFamilyDeclarationV1,
497 source: &[u8],
498 ) -> Result<Self, TransitionFamilyError> {
499 if source.len() as u64 > TRANSITION_FAMILY_V1_MAX_SOURCE_BYTES {
500 return Err(TransitionFamilyError::SourceTooLarge);
501 }
502 let declaration = declaration.canonicalize()?;
507 let normalized_identity = InputIdentity::from_bytes(&declaration.normalized_jcs()?);
508 Ok(Self {
509 declaration,
510 source_identity: InputIdentity::from_bytes(source),
511 normalized_identity,
512 })
513 }
514 pub fn declaration(&self) -> &TransitionFamilyDeclarationV1 {
516 &self.declaration
517 }
518 pub fn source_identity(&self) -> &InputIdentity {
520 &self.source_identity
521 }
522 pub fn normalized_identity(&self) -> &InputIdentity {
524 &self.normalized_identity
525 }
526}
527
528#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
530#[non_exhaustive]
531pub enum TransitionFamilyError {
532 #[error("transition-family source exceeds the V1 byte cap")]
534 SourceTooLarge,
535 #[error("transition-family normalized declaration exceeds the V1 byte cap")]
537 NormalizedTooLarge,
538 #[error("invalid transition-family {field}")]
540 InvalidText {
541 field: &'static str,
543 },
544 #[error("invalid document transition-family identifier")]
546 InvalidDocumentFamilyId,
547 #[error("invalid transition-family tolerance {field}")]
549 InvalidTolerance {
550 field: &'static str,
552 },
553 #[error("transition family requires at least two members")]
555 TooFewMembers,
556 #[error("transition family exceeds the member cap")]
558 TooManyMembers,
559 #[error("transition declaration exceeds the family cap")]
561 TooManyFamilies,
562 #[error("transition declaration exceeds the aggregate member cap")]
564 TooManyAggregateMembers,
565 #[error("duplicate transition-family member")]
567 DuplicateMember,
568 #[error("duplicate transition-family family id")]
570 DuplicateFamily,
571 #[error("collection transition declaration requires at least one family")]
573 EmptyCollection,
574 #[error("collection transition-family id is outside its collection owner")]
576 CollectionFamilyOutsideOwner,
577 #[error("collection transition-family member is outside its collection owner")]
579 CollectionMemberOutsideOwner,
580 #[error("bound collection manifest is too large")]
582 ManifestTooLarge,
583}
584
585fn validate_text(
586 field: &'static str,
587 value: &str,
588 allow_empty: bool,
589) -> Result<(), TransitionFamilyError> {
590 if (!allow_empty && value.is_empty()) || value.len() > TRANSITION_FAMILY_V1_MAX_STRING_BYTES {
591 return Err(TransitionFamilyError::InvalidText { field });
592 }
593 Ok(())
594}
595
596fn validate_document_family_id(value: &str) -> Result<(), TransitionFamilyError> {
597 if value.is_empty() || value.len() > TRANSITION_FAMILY_V1_MAX_DOCUMENT_FAMILY_ID_BYTES {
598 return Err(TransitionFamilyError::InvalidDocumentFamilyId);
599 }
600 let mut bytes = value.bytes();
601 let Some(first) = bytes.next() else {
602 return Err(TransitionFamilyError::InvalidDocumentFamilyId);
603 };
604 if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
605 return Err(TransitionFamilyError::InvalidDocumentFamilyId);
606 }
607 if bytes.any(|byte| {
608 !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-'))
609 }) {
610 return Err(TransitionFamilyError::InvalidDocumentFamilyId);
611 }
612 Ok(())
613}
614
615fn validate_document_members(
616 members: &[DocumentTransitionFamilyMemberV1],
617) -> Result<(), TransitionFamilyError> {
618 validate_member_count(members.len())?;
619 let mut seen = BTreeSet::new();
620 for member in members {
621 if !seen.insert((member.take_index, member.take_name.as_str())) {
622 return Err(TransitionFamilyError::DuplicateMember);
623 }
624 }
625 Ok(())
626}
627
628fn validate_collection_members(
629 members: &[CollectionTransitionFamilyMemberV1],
630) -> Result<(), TransitionFamilyError> {
631 validate_member_count(members.len())?;
632 let mut seen = BTreeSet::new();
633 for member in members {
634 if !seen.insert(member.logical_id.clone()) {
635 return Err(TransitionFamilyError::DuplicateMember);
636 }
637 }
638 Ok(())
639}
640
641fn validate_member_count(count: usize) -> Result<(), TransitionFamilyError> {
642 if count < 2 {
643 return Err(TransitionFamilyError::TooFewMembers);
644 }
645 if count > TRANSITION_FAMILY_V1_MAX_MEMBERS_PER_FAMILY {
646 return Err(TransitionFamilyError::TooManyMembers);
647 }
648 Ok(())
649}
650
651fn validate_family_count(count: usize) -> Result<(), TransitionFamilyError> {
652 if count > TRANSITION_FAMILY_V1_MAX_FAMILIES {
653 Err(TransitionFamilyError::TooManyFamilies)
654 } else {
655 Ok(())
656 }
657}
658
659fn validate_aggregate_document(
660 families: &[DocumentTransitionFamilyV1],
661) -> Result<(), TransitionFamilyError> {
662 if families
663 .iter()
664 .map(|family| family.members.len())
665 .sum::<usize>()
666 > TRANSITION_FAMILY_V1_MAX_AGGREGATE_MEMBERS
667 {
668 Err(TransitionFamilyError::TooManyAggregateMembers)
669 } else {
670 Ok(())
671 }
672}
673
674fn validate_aggregate_collection(
675 families: &[CollectionTransitionFamilyV1],
676) -> Result<(), TransitionFamilyError> {
677 if families
678 .iter()
679 .map(|family| family.members.len())
680 .sum::<usize>()
681 > TRANSITION_FAMILY_V1_MAX_AGGREGATE_MEMBERS
682 {
683 Err(TransitionFamilyError::TooManyAggregateMembers)
684 } else {
685 Ok(())
686 }
687}
688
689struct BoundedWriter {
690 bytes: Vec<u8>,
691 maximum: usize,
692}
693impl BoundedWriter {
694 fn new(maximum: usize) -> Self {
695 Self {
696 bytes: Vec::new(),
697 maximum,
698 }
699 }
700 fn into_inner(self) -> Vec<u8> {
701 self.bytes
702 }
703}
704impl Write for BoundedWriter {
705 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
706 let total = self
707 .bytes
708 .len()
709 .checked_add(bytes.len())
710 .ok_or_else(|| io::Error::other("normalized transition declaration too large"))?;
711 if total > self.maximum {
712 return Err(io::Error::other(
713 "normalized transition declaration too large",
714 ));
715 }
716 self.bytes.extend_from_slice(bytes);
717 Ok(bytes.len())
718 }
719 fn flush(&mut self) -> io::Result<()> {
720 Ok(())
721 }
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 fn tolerances() -> TransitionFamilyTolerancesV1 {
729 TransitionFamilyTolerancesV1::new(0.05, 5.0, 0.0).unwrap()
730 }
731 fn family(id: &str, names: &[&str]) -> DocumentTransitionFamilyV1 {
732 DocumentTransitionFamilyV1::new(
733 id.into(),
734 TransitionFamilyBoundaryV1::Both,
735 tolerances(),
736 names
737 .iter()
738 .enumerate()
739 .map(|(index, name)| {
740 DocumentTransitionFamilyMemberV1::new(index as u64, (*name).into()).unwrap()
741 })
742 .collect(),
743 )
744 .unwrap()
745 }
746
747 #[test]
748 fn document_sorting_preserves_declared_member_order_and_distinct_identities() {
749 let declaration = TransitionFamilyDeclarationV1::document(vec![
750 family("z", &["Z", "A"]),
751 family("a", &["B", "C"]),
752 ])
753 .unwrap();
754 let TransitionFamilyDeclarationV1::Document { families, .. } = &declaration else {
755 panic!("document declaration")
756 };
757 assert_eq!(
758 families
759 .iter()
760 .map(|family| family.family_id())
761 .collect::<Vec<_>>(),
762 ["a", "z"]
763 );
764 assert_eq!(
765 families[1]
766 .members()
767 .iter()
768 .map(|member| member.take_name())
769 .collect::<Vec<_>>(),
770 ["Z", "A"]
771 );
772 let input = TransitionFamilyDeclarationInputV1::new(declaration, b"[source]").unwrap();
773 assert_ne!(input.source_identity(), input.normalized_identity());
774 }
775
776 #[test]
777 fn rejects_invalid_tolerances_identifiers_and_duplicate_members() {
778 assert!(TransitionFamilyTolerancesV1::new(f64::NAN, 0.0, 0.0).is_err());
779 assert_eq!(
780 TransitionFamilyTolerancesV1::new(-0.01, 0.0, 0.0),
781 Err(TransitionFamilyError::InvalidTolerance {
782 field: "translation_m"
783 })
784 );
785 assert!(
786 DocumentTransitionFamilyV1::new(
787 "Upper".into(),
788 TransitionFamilyBoundaryV1::Entry,
789 tolerances(),
790 vec![]
791 )
792 .is_err()
793 );
794 assert!(
795 DocumentTransitionFamilyV1::new(
796 "ok".into(),
797 TransitionFamilyBoundaryV1::Entry,
798 tolerances(),
799 vec![
800 DocumentTransitionFamilyMemberV1::new(0, "same".into()).unwrap(),
801 DocumentTransitionFamilyMemberV1::new(0, "same".into()).unwrap()
802 ]
803 )
804 .is_err()
805 );
806 assert_eq!(
807 DocumentTransitionFamilyV1::new(
808 "ok".into(),
809 TransitionFamilyBoundaryV1::Entry,
810 tolerances(),
811 vec![DocumentTransitionFamilyMemberV1::new(0, "one".into()).unwrap()]
812 ),
813 Err(TransitionFamilyError::TooFewMembers)
814 );
815 assert!(
816 DocumentTransitionFamilyV1::new(
817 "bad/slash".into(),
818 TransitionFamilyBoundaryV1::Entry,
819 tolerances(),
820 vec![
821 DocumentTransitionFamilyMemberV1::new(0, "one".into()).unwrap(),
822 DocumentTransitionFamilyMemberV1::new(1, "two".into()).unwrap(),
823 ]
824 )
825 .is_err()
826 );
827 assert!(
828 DocumentTransitionFamilyV1::new(
829 "tuple".into(),
830 TransitionFamilyBoundaryV1::Entry,
831 tolerances(),
832 vec![
833 DocumentTransitionFamilyMemberV1::new(0, "same-name".into()).unwrap(),
834 DocumentTransitionFamilyMemberV1::new(1, "same-name".into()).unwrap(),
835 ]
836 )
837 .is_ok()
838 );
839 }
840
841 #[test]
842 fn collection_families_and_members_must_share_the_manifest_namespace() {
843 let manifest = TransitionFamilyManifestIdentityV1::new(
844 CollectionIdV1::new("com.example").unwrap(),
845 InputIdentity::from_bytes(b"manifest"),
846 )
847 .unwrap();
848 let members = |first: &str, second: &str| {
849 vec![
850 CollectionTransitionFamilyMemberV1::new(
851 CollectionLogicalIdV1::new(first).unwrap(),
852 CollectionSourceKeyV1::new("source").unwrap(),
853 0,
854 "one".into(),
855 )
856 .unwrap(),
857 CollectionTransitionFamilyMemberV1::new(
858 CollectionLogicalIdV1::new(second).unwrap(),
859 CollectionSourceKeyV1::new("source").unwrap(),
860 1,
861 "two".into(),
862 )
863 .unwrap(),
864 ]
865 };
866 let family = |members| {
867 CollectionTransitionFamilyV1::new(
868 CollectionLogicalIdV1::new("com.example/family").unwrap(),
869 TransitionFamilyBoundaryV1::Entry,
870 tolerances(),
871 members,
872 )
873 .unwrap()
874 };
875 let outside_family = CollectionTransitionFamilyV1::new(
876 CollectionLogicalIdV1::new("other.example/family").unwrap(),
877 TransitionFamilyBoundaryV1::Entry,
878 tolerances(),
879 members("com.example/one", "com.example/two"),
880 )
881 .unwrap();
882 assert_eq!(
883 TransitionFamilyDeclarationV1::collection(manifest.clone(), vec![outside_family]),
884 Err(TransitionFamilyError::CollectionFamilyOutsideOwner)
885 );
886 assert_eq!(
887 TransitionFamilyDeclarationV1::collection(
888 manifest.clone(),
889 vec![family(members("com.example/one", "other.example/two"))]
890 ),
891 Err(TransitionFamilyError::CollectionMemberOutsideOwner)
892 );
893 assert!(
894 TransitionFamilyDeclarationV1::collection(
895 manifest,
896 vec![family(members("com.example/one", "com.example/two"))]
897 )
898 .is_ok()
899 );
900 }
901
902 #[test]
903 fn direct_identity_and_manifest_bounds_refuse_first_excess() {
904 let declaration =
905 TransitionFamilyDeclarationV1::document(vec![family("ok", &["A", "B"])]).unwrap();
906 assert_eq!(
907 TransitionFamilyDeclarationInputV1::new(
908 declaration,
909 &vec![b'x'; TRANSITION_FAMILY_V1_MAX_SOURCE_BYTES as usize + 1]
910 ),
911 Err(TransitionFamilyError::SourceTooLarge)
912 );
913 assert_eq!(
914 TransitionFamilyManifestIdentityV1::new(
915 CollectionIdV1::new("com.example").unwrap(),
916 InputIdentity::from_sha256_digest(
917 [0; 32],
918 crate::COLLECTION_MANIFEST_V1_MAX_MANIFEST_BYTES + 1
919 )
920 ),
921 Err(TransitionFamilyError::ManifestTooLarge)
922 );
923 }
924
925 #[test]
926 fn limits_are_inclusive_and_the_first_aggregate_member_over_is_rejected() {
927 let exact_member_count = (0..TRANSITION_FAMILY_V1_MAX_MEMBERS_PER_FAMILY)
928 .map(|index| {
929 DocumentTransitionFamilyMemberV1::new(index as u64, format!("take-{index}"))
930 .unwrap()
931 })
932 .collect();
933 assert!(
934 DocumentTransitionFamilyV1::new(
935 "exact".into(),
936 TransitionFamilyBoundaryV1::Entry,
937 tolerances(),
938 exact_member_count,
939 )
940 .is_ok()
941 );
942
943 let family = |id: String, members| {
944 DocumentTransitionFamilyV1::new(
945 id,
946 TransitionFamilyBoundaryV1::Entry,
947 tolerances(),
948 (0..members)
949 .map(|index| {
950 DocumentTransitionFamilyMemberV1::new(index as u64, format!("{index}"))
951 .unwrap()
952 })
953 .collect(),
954 )
955 .unwrap()
956 };
957 let mut families = (0..4)
958 .map(|index| family(format!("family-{index}"), 4_096))
959 .collect::<Vec<_>>();
960 families.push(family("family-over".into(), 2));
961 assert_eq!(
962 TransitionFamilyDeclarationV1::document(families),
963 Err(TransitionFamilyError::TooManyAggregateMembers)
964 );
965 }
966
967 #[test]
968 fn normalized_jcs_is_exact_and_uses_the_bounded_writer_seam() {
969 let declaration =
970 TransitionFamilyDeclarationV1::document(vec![family("walk", &["Walk", "Run"])])
971 .unwrap();
972 assert_eq!(
973 String::from_utf8(declaration.normalized_jcs().unwrap()).unwrap(),
974 "{\"families\":[{\"basis\":{\"rotation\":\"skeleton-local-degrees\",\"time\":\"normalized-clip\",\"translation\":\"skeleton-local-metres\"},\"boundary\":\"both\",\"family_id\":\"walk\",\"members\":[{\"take_index\":0,\"take_name\":\"Walk\"},{\"take_index\":1,\"take_name\":\"Run\"}],\"tolerances\":{\"rotation_deg\":5,\"time_normalized\":0,\"translation_m\":0.05}}],\"schema\":\"urn:animsmith:schema:transition-family:1\",\"schema_version\":1,\"scope\":\"document\"}"
975 );
976 assert_eq!(
977 declaration.normalized_jcs_bounded(1),
978 Err(TransitionFamilyError::NormalizedTooLarge)
979 );
980 let input = TransitionFamilyDeclarationInputV1::new(declaration, b"exact source").unwrap();
981 assert_eq!(input.normalized_identity().bytes(), 408);
982 assert_eq!(
983 input.normalized_identity().sha256(),
984 "f9b6b85b4fa066324b6c9bbe2ea0fe1ff6c217b3175ef964054b08c03b327cd0"
985 );
986 }
987
988 #[test]
989 fn source_identity_boundary_revalidates_internal_declaration_construction() {
990 let invalid = TransitionFamilyDeclarationV1::Document {
991 schema: "forged",
992 schema_version: 99,
993 families: vec![family("same", &["A", "B"]), family("same", &["C", "D"])],
994 };
995 assert_eq!(
996 invalid.normalized_jcs(),
997 Err(TransitionFamilyError::DuplicateFamily)
998 );
999 assert_eq!(
1000 TransitionFamilyDeclarationInputV1::new(invalid, b"source"),
1001 Err(TransitionFamilyError::DuplicateFamily)
1002 );
1003 let noncanonical = TransitionFamilyDeclarationV1::Document {
1004 schema: "forged",
1005 schema_version: 99,
1006 families: vec![family("z", &["Z", "A"]), family("a", &["B", "C"])],
1007 };
1008 let bound = TransitionFamilyDeclarationInputV1::new(noncanonical, b"source").unwrap();
1009 assert_eq!(
1010 bound
1011 .declaration()
1012 .document_families()
1013 .unwrap()
1014 .iter()
1015 .map(|family| family.family_id())
1016 .collect::<Vec<_>>(),
1017 ["a", "z"]
1018 );
1019 }
1020
1021 #[test]
1022 fn collection_sorting_preserves_declared_member_order() {
1023 let member = |id: &str, take_name: &str| {
1024 CollectionTransitionFamilyMemberV1::new(
1025 CollectionLogicalIdV1::new(id).unwrap(),
1026 CollectionSourceKeyV1::new("source").unwrap(),
1027 0,
1028 take_name.into(),
1029 )
1030 .unwrap()
1031 };
1032 let family = |id: &str, members| {
1033 CollectionTransitionFamilyV1::new(
1034 CollectionLogicalIdV1::new(id).unwrap(),
1035 TransitionFamilyBoundaryV1::Entry,
1036 tolerances(),
1037 members,
1038 )
1039 .unwrap()
1040 };
1041 let manifest = TransitionFamilyManifestIdentityV1::new(
1042 CollectionIdV1::new("com.example").unwrap(),
1043 InputIdentity::from_bytes(b"manifest"),
1044 )
1045 .unwrap();
1046 let declaration = TransitionFamilyDeclarationV1::collection(
1047 manifest,
1048 vec![
1049 family(
1050 "com.example/z",
1051 vec![
1052 member("com.example/z-first", "Z"),
1053 member("com.example/z-last", "A"),
1054 ],
1055 ),
1056 family(
1057 "com.example/a",
1058 vec![
1059 member("com.example/a-first", "B"),
1060 member("com.example/a-last", "C"),
1061 ],
1062 ),
1063 ],
1064 )
1065 .unwrap();
1066 let TransitionFamilyDeclarationV1::Collection { families, .. } = declaration else {
1067 panic!("collection")
1068 };
1069 assert_eq!(families[0].family_id().as_str(), "com.example/a");
1070 assert_eq!(families[1].members()[0].take_name(), "Z");
1071 assert_eq!(families[1].members()[1].take_name(), "A");
1072 }
1073}