1use std::collections::{HashMap, HashSet};
17
18use crate::client::SunoClient;
19use crate::clock::Clock;
20use crate::error::Result;
21use crate::http::Http;
22use crate::model::Clip;
23
24const ZERO_UUID: &str = "00000000-0000-0000-0000-000000000000";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum EdgeType {
30 Cover,
32 Remaster,
34 SpeedEdit,
36 Edit,
38 Extend,
40 SectionReplace,
42 Stitch,
44 Derived,
46 Uploaded,
48}
49
50impl EdgeType {
51 pub fn label(self) -> &'static str {
53 match self {
54 EdgeType::Cover => "Cover of",
55 EdgeType::Remaster => "Remaster of",
56 EdgeType::SpeedEdit => "Speed-edited from",
57 EdgeType::Edit => "Edited from",
58 EdgeType::Extend => "Extended from",
59 EdgeType::SectionReplace => "Section replaced from",
60 EdgeType::Stitch => "Stitched from",
61 EdgeType::Derived => "Derived from",
62 EdgeType::Uploaded => "Uploaded",
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum EdgeRole {
70 Primary,
72 Secondary,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Edge {
79 pub parent_id: String,
81 pub edge_type: EdgeType,
83 pub role: EdgeRole,
85 pub ordinal: u32,
87 pub source_field: &'static str,
89}
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub struct ResolveOpts {
94 pub max_gap_fills: u32,
96 pub hop_cap: u32,
98}
99
100impl Default for ResolveOpts {
101 fn default() -> Self {
102 Self {
103 max_gap_fills: 200,
104 hop_cap: 64,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum ResolveStatus {
112 Resolved,
114 External,
117 Unresolved,
119 Cycle,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct RootInfo {
126 pub root_id: String,
128 pub root_title: String,
130 pub status: ResolveStatus,
132}
133
134#[derive(Debug, Clone, PartialEq)]
142pub struct Resolution {
143 pub roots: HashMap<String, RootInfo>,
146 pub gap_filled: Vec<Clip>,
149 pub bridges: Vec<(String, String)>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct LineageContext {
166 pub root_id: String,
168 pub root_title: String,
180 pub root_date: String,
193 pub parent_id: String,
195 pub edge_type: Option<EdgeType>,
197 pub status: ResolveStatus,
199}
200
201impl LineageContext {
202 pub fn for_clip(clip: &Clip, resolution: &Resolution) -> LineageContext {
211 let (root_id, root_title, status) = match resolution.roots.get(&clip.id) {
212 Some(info) => (info.root_id.clone(), info.root_title.clone(), info.status),
213 None => (clip.id.clone(), clip.title.clone(), ResolveStatus::Resolved),
214 };
215 let (parent_id, edge_type) = match immediate_parent(clip) {
216 Some((id, edge)) => (id, Some(edge)),
217 None => (String::new(), None),
218 };
219 LineageContext {
220 root_id,
221 root_title,
222 root_date: clip.created_at.clone(),
223 parent_id,
224 edge_type,
225 status,
226 }
227 }
228
229 pub fn own_root(clip: &Clip) -> LineageContext {
234 LineageContext {
235 root_id: clip.id.clone(),
236 root_title: clip.title.clone(),
237 root_date: clip.created_at.clone(),
238 parent_id: String::new(),
239 edge_type: None,
240 status: ResolveStatus::Resolved,
241 }
242 }
243
244 pub fn album(&self, own_title: &str) -> String {
251 let root_title = self.root_title.trim();
252 if !root_title.is_empty() && self.root_title != own_title {
253 self.root_title.clone()
254 } else {
255 own_title.to_owned()
256 }
257 }
258
259 pub fn year(&self, own_created_at: &str) -> String {
268 let root_year = year_of(&self.root_date);
269 if root_year.is_empty() {
270 year_of(own_created_at)
271 } else {
272 root_year
273 }
274 }
275}
276
277fn year_of(created_at: &str) -> String {
280 created_at.chars().take(4).collect()
281}
282
283pub fn edge_type(clip: &Clip) -> Option<EdgeType> {
296 let task = clip.task.as_str();
297 let clip_type = clip.clip_type.as_str();
298
299 if task == "infill" || task == "fixed_infill" {
300 Some(EdgeType::SectionReplace)
301 } else if task == "extend" {
302 Some(EdgeType::Extend)
303 } else if clip_type == "concat" {
304 Some(EdgeType::Stitch)
305 } else if clip_type == "edit_speed" {
306 Some(EdgeType::SpeedEdit)
307 } else if task == "cover" {
308 Some(EdgeType::Cover)
309 } else if clip_type == "upsample" || task == "upsample" {
310 Some(EdgeType::Remaster)
311 } else if clip_type == "edit_v3_export" {
312 Some(EdgeType::Edit)
313 } else if normalise_id(&clip.edited_clip_id).is_some() {
314 Some(EdgeType::Derived)
315 } else {
316 None
317 }
318}
319
320pub fn immediate_parent(clip: &Clip) -> Option<(String, EdgeType)> {
328 primary_parent(clip).map(|(id, edge, _field)| (id, edge))
329}
330
331pub fn lineage_edges(clip: &Clip) -> Vec<Edge> {
342 let Some(edge_type) = edge_type(clip) else {
343 return Vec::new();
344 };
345
346 let mut edges = Vec::new();
347 if let Some((parent_id, _edge, source_field)) = primary_parent(clip) {
348 edges.push(Edge {
349 parent_id,
350 edge_type,
351 role: EdgeRole::Primary,
352 ordinal: 0,
353 source_field,
354 });
355 }
356
357 match edge_type {
358 EdgeType::Stitch => {
359 for (ordinal, entry) in clip.concat_history.iter().enumerate().skip(1) {
360 if let Some(id) = normalise_id(&entry.id) {
361 edges.push(Edge {
362 parent_id: id,
363 edge_type,
364 role: EdgeRole::Secondary,
365 ordinal: ordinal as u32,
366 source_field: "concat_history",
367 });
368 }
369 }
370 }
371 EdgeType::SectionReplace => {
372 if let Some(future) = normalise_id(&clip.override_future_clip_id)
373 && edges
374 .first()
375 .is_none_or(|primary| primary.parent_id != future)
376 {
377 edges.push(Edge {
378 parent_id: future,
379 edge_type,
380 role: EdgeRole::Secondary,
381 ordinal: 1,
382 source_field: "override_future_clip_id",
383 });
384 }
385 }
386 _ => {}
387 }
388
389 edges
390}
391
392pub async fn resolve_roots(
411 clips: &[Clip],
412 archived_parents: &HashMap<String, String>,
413 client: &mut SunoClient<impl Clock>,
414 http: &impl Http,
415 opts: ResolveOpts,
416) -> Result<Resolution> {
417 let mut resolver = Resolver::new(clips, opts, archived_parents);
418 resolver.run(client, http).await?;
419 Ok(resolver.into_resolution(clips))
420}
421
422fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
426 let edge = edge_type(clip)?;
427 let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
428 let concat_head = clip
429 .concat_history
430 .first()
431 .map_or("", |entry| entry.id.as_str());
432
433 let candidates: Vec<(&str, &'static str)> = match edge {
434 EdgeType::SectionReplace => vec![
435 (
436 clip.override_history_clip_id.as_str(),
437 "override_history_clip_id",
438 ),
439 (
440 clip.override_future_clip_id.as_str(),
441 "override_future_clip_id",
442 ),
443 (history_head, "history"),
444 (clip.edited_clip_id.as_str(), "edited_clip_id"),
445 ],
446 EdgeType::Extend => vec![
447 (history_head, "history"),
448 (clip.edited_clip_id.as_str(), "edited_clip_id"),
449 ],
450 EdgeType::Stitch => vec![
451 (concat_head, "concat_history"),
452 (clip.edited_clip_id.as_str(), "edited_clip_id"),
453 ],
454 EdgeType::SpeedEdit => vec![
455 (clip.speed_clip_id.as_str(), "speed_clip_id"),
456 (clip.edited_clip_id.as_str(), "edited_clip_id"),
457 ],
458 EdgeType::Cover => vec![
459 (clip.cover_clip_id.as_str(), "cover_clip_id"),
460 (clip.edited_clip_id.as_str(), "edited_clip_id"),
461 ],
462 EdgeType::Remaster => vec![
463 (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
464 (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
465 (clip.edited_clip_id.as_str(), "edited_clip_id"),
466 ],
467 EdgeType::Edit | EdgeType::Derived => {
468 vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
469 }
470 EdgeType::Uploaded => vec![],
471 };
472
473 candidates
474 .into_iter()
475 .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
476}
477
478fn normalise_id(id: &str) -> Option<String> {
481 let id = id.strip_prefix("m_").unwrap_or(id);
482 if id.is_empty() || id == ZERO_UUID {
483 None
484 } else {
485 Some(id.to_string())
486 }
487}
488
489enum Walk {
491 Resolved,
493 Blocked(String),
495}
496
497struct Resolver<'a> {
505 index: HashMap<String, Clip>,
506 archived_parents: &'a HashMap<String, String>,
511 gap_filled: HashSet<String>,
512 bridges: HashMap<String, String>,
513 external: HashSet<String>,
514 memo: HashMap<String, RootInfo>,
515 targets: Vec<String>,
516 budget: u32,
517 hop_cap: u32,
518}
519
520impl<'a> Resolver<'a> {
521 fn new(
522 clips: &[Clip],
523 opts: ResolveOpts,
524 archived_parents: &'a HashMap<String, String>,
525 ) -> Self {
526 let index = clips
527 .iter()
528 .map(|clip| (clip.id.clone(), clip.clone()))
529 .collect();
530 let targets = clips.iter().map(|clip| clip.id.clone()).collect();
531 Self {
532 index,
533 archived_parents,
534 gap_filled: HashSet::new(),
535 bridges: HashMap::new(),
536 external: HashSet::new(),
537 memo: HashMap::new(),
538 targets,
539 budget: opts.max_gap_fills,
540 hop_cap: opts.hop_cap,
541 }
542 }
543
544 async fn run(&mut self, client: &mut SunoClient<impl Clock>, http: &impl Http) -> Result<()> {
547 let targets = self.targets.clone();
548 loop {
549 let mut frontier: Vec<String> = Vec::new();
550 let mut seen: HashSet<String> = HashSet::new();
551 let mut blocked: Vec<(String, String)> = Vec::new();
552
553 for target in &targets {
554 if self.memo.contains_key(target) {
555 continue;
556 }
557 if let Walk::Blocked(missing) = self.walk(target) {
558 if seen.insert(missing.clone()) {
559 frontier.push(missing.clone());
560 }
561 blocked.push((target.clone(), missing));
562 }
563 }
564
565 if blocked.is_empty() {
566 break;
567 }
568 if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
569 self.finalise_external(&blocked);
570 break;
571 }
572 }
573 Ok(())
574 }
575
576 fn walk(&mut self, start: &str) -> Walk {
580 if self.memo.contains_key(start) {
581 return Walk::Resolved;
582 }
583 let mut chain: Vec<String> = Vec::new();
584 let mut visited: HashSet<String> = HashSet::new();
585 let mut current = start.to_string();
586 let mut hops = 0u32;
587
588 loop {
589 if let Some(info) = self.memo.get(¤t).cloned() {
590 self.assign(&chain, &info);
591 return Walk::Resolved;
592 }
593 if visited.contains(¤t) {
594 let info = self.terminal(¤t, ResolveStatus::Cycle);
595 self.assign(&chain, &info);
596 self.memo.insert(current, info);
597 return Walk::Resolved;
598 }
599 if hops >= self.hop_cap {
600 let info = self.terminal(¤t, ResolveStatus::Unresolved);
601 self.assign(&chain, &info);
602 self.memo.insert(current, info);
603 return Walk::Resolved;
604 }
605
606 let parent_id = if let Some(clip) = self.index.get(¤t) {
613 immediate_parent(clip).map(|(id, _edge)| id)
614 } else if let Some(parent) = self.archived_parents.get(¤t) {
615 Some(parent.clone())
616 } else {
617 return Walk::Blocked(current);
618 };
619
620 let Some(parent_id) = parent_id else {
621 let info = RootInfo {
622 root_id: current.clone(),
623 root_title: self.title_of(¤t),
624 status: ResolveStatus::Resolved,
625 };
626 self.assign(&chain, &info);
627 self.memo.insert(current, info);
628 return Walk::Resolved;
629 };
630
631 visited.insert(current.clone());
632 chain.push(current);
633
634 if self.index.contains_key(&parent_id) || self.archived_parents.contains_key(&parent_id)
635 {
636 current = parent_id;
637 } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
638 visited.insert(parent_id);
639 current = bridged;
640 } else if self.external.contains(&parent_id) {
641 let info = self.terminal(&parent_id, ResolveStatus::External);
642 self.assign(&chain, &info);
643 self.memo.insert(parent_id, info);
644 return Walk::Resolved;
645 } else {
646 return Walk::Blocked(parent_id);
647 }
648 hops += 1;
649 }
650 }
651
652 async fn gap_fill(
656 &mut self,
657 client: &mut SunoClient<impl Clock>,
658 http: &impl Http,
659 frontier: &[String],
660 ) -> Result<bool> {
661 let mut want: Vec<String> = frontier
662 .iter()
663 .filter(|id| !self.known(id))
664 .cloned()
665 .collect();
666 if want.is_empty() {
667 return Ok(false);
668 }
669 want.sort();
670 let take = (self.budget as usize).min(want.len());
671 let batch: Vec<String> = want.into_iter().take(take).collect();
672 self.budget -= batch.len() as u32;
673
674 let refs: Vec<&str> = batch.iter().map(String::as_str).collect();
675 let fetched = client.get_clips_by_ids(http, &refs).await?;
676
677 let mut returned: HashSet<String> = HashSet::new();
678 let mut progressed = false;
679 for clip in fetched {
680 returned.insert(clip.id.clone());
681 if self.insert_ancestor(clip) {
682 progressed = true;
683 }
684 }
685
686 for id in &batch {
687 if returned.contains(id) {
688 continue;
689 }
690 match client.get_clip_parent(http, id).await? {
691 Some(parent) => {
692 let parent_id = parent.id.clone();
693 self.insert_ancestor(parent);
694 self.bridges.insert(id.clone(), parent_id);
695 progressed = true;
696 }
697 None => {
698 self.external.insert(id.clone());
699 progressed = true;
700 }
701 }
702 }
703
704 Ok(progressed)
705 }
706
707 fn insert_ancestor(&mut self, clip: Clip) -> bool {
710 if clip.id.is_empty() || self.index.contains_key(&clip.id) {
711 return false;
712 }
713 self.gap_filled.insert(clip.id.clone());
714 self.index.insert(clip.id.clone(), clip);
715 true
716 }
717
718 fn known(&self, id: &str) -> bool {
720 self.index.contains_key(id)
721 || self.archived_parents.contains_key(id)
722 || self.bridges.contains_key(id)
723 || self.external.contains(id)
724 }
725
726 fn finalise_external(&mut self, blocked: &[(String, String)]) {
729 for (target, missing) in blocked {
730 if self.memo.contains_key(target) {
731 continue;
732 }
733 let info = self.terminal(missing, ResolveStatus::External);
734 self.memo.insert(target.clone(), info);
735 }
736 }
737
738 fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
740 RootInfo {
741 root_id: id.to_string(),
742 root_title: self.title_of(id),
743 status,
744 }
745 }
746
747 fn title_of(&self, id: &str) -> String {
749 self.index
750 .get(id)
751 .map_or_else(String::new, |clip| clip.title.clone())
752 }
753
754 fn assign(&mut self, chain: &[String], info: &RootInfo) {
756 for id in chain {
757 self.memo.insert(id.clone(), info.clone());
758 }
759 }
760
761 fn into_resolution(self, clips: &[Clip]) -> Resolution {
764 let mut roots = HashMap::with_capacity(clips.len());
765 for clip in clips {
766 let info = self
767 .memo
768 .get(&clip.id)
769 .cloned()
770 .unwrap_or_else(|| RootInfo {
771 root_id: clip.id.clone(),
772 root_title: clip.title.clone(),
773 status: ResolveStatus::Unresolved,
774 });
775 roots.insert(clip.id.clone(), info);
776 }
777
778 let mut gap_filled: Vec<Clip> = self
779 .gap_filled
780 .iter()
781 .filter_map(|id| self.index.get(id).cloned())
782 .collect();
783 gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
784
785 let mut bridges: Vec<(String, String)> = self
786 .bridges
787 .iter()
788 .map(|(child, parent)| (child.clone(), parent.clone()))
789 .collect();
790 bridges.sort();
791
792 Resolution {
793 roots,
794 gap_filled,
795 bridges,
796 }
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use crate::auth::ClerkAuth;
804 use crate::model::HistoryEntry;
805 use crate::testutil::{RecordingClock, Reply, ScriptedHttp};
806
807 fn history(id: &str) -> HistoryEntry {
808 HistoryEntry {
809 id: id.to_owned(),
810 ..Default::default()
811 }
812 }
813
814 fn chain1_clips() -> Vec<Clip> {
818 vec![
819 Clip {
820 id: "40068b49".into(),
821 title: "Zac and the Sea Eagles (Lullaby Version)".into(),
822 clip_type: "upsample".into(),
823 task: "upsample".into(),
824 is_remix: true,
825 upsample_clip_id: "52962dae".into(),
826 edited_clip_id: "52962dae".into(),
827 ..Default::default()
828 },
829 Clip {
830 id: "52962dae".into(),
831 title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
832 clip_type: "gen".into(),
833 task: "cover".into(),
834 is_remix: true,
835 cover_clip_id: "536e1b92".into(),
836 edited_clip_id: "536e1b92".into(),
837 ..Default::default()
838 },
839 Clip {
840 id: "536e1b92".into(),
841 title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
842 clip_type: "upsample".into(),
843 task: "upsample".into(),
844 is_remix: true,
845 upsample_clip_id: "b9f27ee1".into(),
846 edited_clip_id: "b9f27ee1".into(),
847 ..Default::default()
848 },
849 Clip {
850 id: "b9f27ee1".into(),
851 title: "Zac and the Sea Eagles (Edit)".into(),
852 clip_type: "gen".into(),
853 task: "cover".into(),
854 is_remix: true,
855 cover_clip_id: "c1997d52".into(),
856 edited_clip_id: "c1997d52".into(),
857 ..Default::default()
858 },
859 Clip {
860 id: "c1997d52".into(),
861 title: "Zac and the Sea Eagles (Rework)".into(),
862 clip_type: "edit_v3_export".into(),
863 edited_clip_id: "dfb59a04".into(),
864 ..Default::default()
865 },
866 Clip {
867 id: "dfb59a04".into(),
868 title: "Zac and the Sea Eagles".into(),
869 clip_type: "gen".into(),
870 ..Default::default()
871 },
872 ]
873 }
874
875 fn authed_client(http: &ScriptedHttp) -> SunoClient<RecordingClock> {
876 let mut auth = ClerkAuth::new("eyJtoken");
877 pollster::block_on(auth.authenticate(http)).unwrap();
878 SunoClient::new(auth, RecordingClock::new())
879 }
880
881 #[test]
882 fn edge_type_labels_read_naturally() {
883 assert_eq!(EdgeType::Cover.label(), "Cover of");
884 assert_eq!(EdgeType::Remaster.label(), "Remaster of");
885 assert_eq!(EdgeType::SpeedEdit.label(), "Speed-edited from");
886 assert_eq!(EdgeType::Edit.label(), "Edited from");
887 assert_eq!(EdgeType::Extend.label(), "Extended from");
888 assert_eq!(EdgeType::SectionReplace.label(), "Section replaced from");
889 assert_eq!(EdgeType::Stitch.label(), "Stitched from");
890 assert_eq!(EdgeType::Derived.label(), "Derived from");
891 assert_eq!(EdgeType::Uploaded.label(), "Uploaded");
892 }
893
894 #[test]
895 fn classifies_remaster_cover_edit_and_root_across_chain1() {
896 let clips = chain1_clips();
897
898 assert_eq!(edge_type(&clips[0]), Some(EdgeType::Remaster));
899 assert_eq!(
900 immediate_parent(&clips[0]),
901 Some(("52962dae".into(), EdgeType::Remaster))
902 );
903
904 assert_eq!(edge_type(&clips[1]), Some(EdgeType::Cover));
905 assert_eq!(
906 immediate_parent(&clips[1]),
907 Some(("536e1b92".into(), EdgeType::Cover))
908 );
909
910 assert_eq!(edge_type(&clips[4]), Some(EdgeType::Edit));
911 assert_eq!(
912 immediate_parent(&clips[4]),
913 Some(("dfb59a04".into(), EdgeType::Edit))
914 );
915
916 assert_eq!(edge_type(&clips[5]), None);
917 assert_eq!(immediate_parent(&clips[5]), None);
918 }
919
920 #[test]
921 fn classifies_speed_edit_from_speed_pointer_without_edited() {
922 let clip = Clip {
924 id: "6e5193b1".into(),
925 title: "Go Xavi Go, Fast. (Drum n' Bass Version)".into(),
926 clip_type: "edit_speed".into(),
927 is_remix: true,
928 speed_clip_id: "2b69882c".into(),
929 ..Default::default()
930 };
931 assert_eq!(edge_type(&clip), Some(EdgeType::SpeedEdit));
932 assert_eq!(
933 immediate_parent(&clip),
934 Some(("2b69882c".into(), EdgeType::SpeedEdit))
935 );
936 }
937
938 #[test]
939 fn empty_task_gen_is_a_root() {
940 let clip = Clip {
942 id: "b4f16694".into(),
943 title: "Go Xavi Go, Fast.".into(),
944 clip_type: "gen".into(),
945 task: String::new(),
946 ..Default::default()
947 };
948 assert_eq!(edge_type(&clip), None);
949 assert_eq!(immediate_parent(&clip), None);
950 }
951
952 #[test]
953 fn classifies_extend_from_history_head() {
954 let clip = Clip {
955 id: "9a3dcb67".into(),
956 title: "Extended".into(),
957 clip_type: "gen".into(),
958 task: "extend".into(),
959 edited_clip_id: "0a3c311a".into(),
960 history: vec![HistoryEntry {
961 id: "0a3c311a".into(),
962 continue_at: Some(115.35),
963 ..Default::default()
964 }],
965 ..Default::default()
966 };
967 assert_eq!(edge_type(&clip), Some(EdgeType::Extend));
968 assert_eq!(
969 immediate_parent(&clip),
970 Some(("0a3c311a".into(), EdgeType::Extend))
971 );
972 }
973
974 #[test]
975 fn classifies_infill_with_override_history_precedence() {
976 let clip = Clip {
978 id: "c0ce5c48".into(),
979 title: "Section replaced".into(),
980 clip_type: "gen".into(),
981 task: "infill".into(),
982 edited_clip_id: "cf37e05f".into(),
983 override_history_clip_id: "d3d28e59".into(),
984 override_future_clip_id: "ea88571e".into(),
985 history: vec![HistoryEntry {
986 id: "cf37e05f".into(),
987 infill: true,
988 infill_start_s: Some(20.4),
989 infill_end_s: Some(24.92),
990 ..Default::default()
991 }],
992 ..Default::default()
993 };
994 assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
995 assert_eq!(
996 immediate_parent(&clip),
997 Some(("d3d28e59".into(), EdgeType::SectionReplace))
998 );
999 }
1000
1001 #[test]
1002 fn fixed_infill_is_also_section_replace() {
1003 let clip = Clip {
1004 task: "fixed_infill".into(),
1005 override_history_clip_id: "past".into(),
1006 edited_clip_id: "edited".into(),
1007 ..Default::default()
1008 };
1009 assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
1010 assert_eq!(
1011 immediate_parent(&clip),
1012 Some(("past".into(), EdgeType::SectionReplace))
1013 );
1014 }
1015
1016 #[test]
1017 fn classifies_stitch_from_concat_base() {
1018 let clip = Clip {
1020 id: "43ba1ce3".into(),
1021 title: "Stitched".into(),
1022 clip_type: "concat".into(),
1023 concat_history: vec![
1024 HistoryEntry {
1025 id: "ead64fbe".into(),
1026 continue_at: Some(149.19),
1027 ..Default::default()
1028 },
1029 history("da47b824"),
1030 ],
1031 ..Default::default()
1032 };
1033 assert_eq!(edge_type(&clip), Some(EdgeType::Stitch));
1034 assert_eq!(
1035 immediate_parent(&clip),
1036 Some(("ead64fbe".into(), EdgeType::Stitch))
1037 );
1038 }
1039
1040 #[test]
1041 fn inherited_concat_history_without_concat_type_is_not_a_stitch() {
1042 let clip = Clip {
1047 clip_type: "gen".into(),
1048 concat_history: vec![history("base"), history("second")],
1049 ..Default::default()
1050 };
1051 assert_eq!(edge_type(&clip), None);
1052 assert_eq!(immediate_parent(&clip), None);
1053 }
1054
1055 #[test]
1056 fn cover_of_a_stitch_classifies_as_cover_not_stitch() {
1057 let clip = Clip {
1061 id: "cov".into(),
1062 title: "Cover of a stitch".into(),
1063 clip_type: "gen".into(),
1064 task: "cover".into(),
1065 cover_clip_id: "stitch-parent".into(),
1066 edited_clip_id: "stitch-parent".into(),
1067 concat_history: vec![history("inherited-base"), history("inherited-seg")],
1068 ..Default::default()
1069 };
1070 assert_eq!(edge_type(&clip), Some(EdgeType::Cover));
1071 assert_eq!(
1072 immediate_parent(&clip),
1073 Some(("stitch-parent".into(), EdgeType::Cover))
1074 );
1075 }
1076
1077 #[test]
1078 fn upload_is_a_root() {
1079 let clip = Clip {
1080 id: "4770ef56".into(),
1081 title: "Uploaded audio".into(),
1082 clip_type: "upload".into(),
1083 ..Default::default()
1084 };
1085 assert_eq!(edge_type(&clip), None);
1086 assert_eq!(immediate_parent(&clip), None);
1087 }
1088
1089 #[test]
1090 fn edited_only_clip_is_derived() {
1091 let clip = Clip {
1093 clip_type: "gen".into(),
1094 task: "chop_sample_condition".into(),
1095 edited_clip_id: "parent-x".into(),
1096 ..Default::default()
1097 };
1098 assert_eq!(edge_type(&clip), Some(EdgeType::Derived));
1099 assert_eq!(
1100 immediate_parent(&clip),
1101 Some(("parent-x".into(), EdgeType::Derived))
1102 );
1103 }
1104
1105 #[test]
1106 fn unmarked_clip_without_pointer_is_a_root() {
1107 let clip = Clip {
1108 clip_type: "gen".into(),
1109 task: "chop_sample_condition".into(),
1110 ..Default::default()
1111 };
1112 assert_eq!(edge_type(&clip), None);
1113 assert_eq!(immediate_parent(&clip), None);
1114 }
1115
1116 #[test]
1117 fn is_remix_does_not_change_classification() {
1118 let base = Clip {
1119 clip_type: "gen".into(),
1120 task: "cover".into(),
1121 cover_clip_id: "root-1".into(),
1122 edited_clip_id: "root-1".into(),
1123 ..Default::default()
1124 };
1125 let mut with_flag = base.clone();
1126 with_flag.is_remix = true;
1127 let mut without_flag = base;
1128 without_flag.is_remix = false;
1129
1130 assert_eq!(edge_type(&with_flag), edge_type(&without_flag));
1131 assert_eq!(
1132 immediate_parent(&with_flag),
1133 immediate_parent(&without_flag)
1134 );
1135 assert_eq!(edge_type(&with_flag), Some(EdgeType::Cover));
1136 assert_eq!(
1137 immediate_parent(&with_flag),
1138 Some(("root-1".into(), EdgeType::Cover))
1139 );
1140 }
1141
1142 #[test]
1143 fn zero_uuid_cover_falls_back_to_edited() {
1144 let clip = Clip {
1145 clip_type: "gen".into(),
1146 task: "cover".into(),
1147 cover_clip_id: ZERO_UUID.into(),
1148 edited_clip_id: "real-parent".into(),
1149 ..Default::default()
1150 };
1151 assert_eq!(
1152 immediate_parent(&clip),
1153 Some(("real-parent".into(), EdgeType::Cover))
1154 );
1155 }
1156
1157 #[test]
1158 fn m_prefix_is_stripped_from_history_and_concat_ids() {
1159 let extend = Clip {
1160 clip_type: "gen".into(),
1161 task: "extend".into(),
1162 history: vec![history("m_abc123")],
1163 ..Default::default()
1164 };
1165 assert_eq!(
1166 immediate_parent(&extend),
1167 Some(("abc123".into(), EdgeType::Extend))
1168 );
1169
1170 let stitch = Clip {
1171 clip_type: "concat".into(),
1172 concat_history: vec![history("m_base"), history("m_second")],
1173 ..Default::default()
1174 };
1175 let edges = lineage_edges(&stitch);
1176 assert_eq!(edges[0].parent_id, "base");
1177 assert_eq!(edges[1].parent_id, "second");
1178 assert_eq!(edges[1].role, EdgeRole::Secondary);
1179 }
1180
1181 #[test]
1182 fn lineage_edges_of_a_root_is_empty() {
1183 let clip = Clip {
1184 clip_type: "gen".into(),
1185 ..Default::default()
1186 };
1187 assert!(lineage_edges(&clip).is_empty());
1188 }
1189
1190 #[test]
1191 fn lineage_edges_records_stitch_secondaries_in_order() {
1192 let clip = Clip {
1193 clip_type: "concat".into(),
1194 concat_history: vec![history("base"), history("seg1"), history("seg2")],
1195 ..Default::default()
1196 };
1197 let edges = lineage_edges(&clip);
1198 assert_eq!(
1199 edges,
1200 vec![
1201 Edge {
1202 parent_id: "base".into(),
1203 edge_type: EdgeType::Stitch,
1204 role: EdgeRole::Primary,
1205 ordinal: 0,
1206 source_field: "concat_history",
1207 },
1208 Edge {
1209 parent_id: "seg1".into(),
1210 edge_type: EdgeType::Stitch,
1211 role: EdgeRole::Secondary,
1212 ordinal: 1,
1213 source_field: "concat_history",
1214 },
1215 Edge {
1216 parent_id: "seg2".into(),
1217 edge_type: EdgeType::Stitch,
1218 role: EdgeRole::Secondary,
1219 ordinal: 2,
1220 source_field: "concat_history",
1221 },
1222 ]
1223 );
1224 }
1225
1226 #[test]
1227 fn lineage_edges_emits_secondaries_when_the_primary_is_absent() {
1228 let clip = Clip {
1232 clip_type: "concat".into(),
1233 concat_history: vec![history(""), history("seg1"), history("seg2")],
1234 ..Default::default()
1235 };
1236 let edges = lineage_edges(&clip);
1237 assert_eq!(
1238 edges,
1239 vec![
1240 Edge {
1241 parent_id: "seg1".into(),
1242 edge_type: EdgeType::Stitch,
1243 role: EdgeRole::Secondary,
1244 ordinal: 1,
1245 source_field: "concat_history",
1246 },
1247 Edge {
1248 parent_id: "seg2".into(),
1249 edge_type: EdgeType::Stitch,
1250 role: EdgeRole::Secondary,
1251 ordinal: 2,
1252 source_field: "concat_history",
1253 },
1254 ],
1255 "secondaries survive an empty primary base segment"
1256 );
1257 }
1258
1259 #[test]
1260 fn lineage_edges_records_infill_future_as_secondary() {
1261 let clip = Clip {
1262 task: "infill".into(),
1263 override_history_clip_id: "past".into(),
1264 override_future_clip_id: "future".into(),
1265 ..Default::default()
1266 };
1267 let edges = lineage_edges(&clip);
1268 assert_eq!(edges[0].parent_id, "past");
1269 assert_eq!(edges[0].role, EdgeRole::Primary);
1270 assert_eq!(edges[0].source_field, "override_history_clip_id");
1271 assert_eq!(
1272 edges[1],
1273 Edge {
1274 parent_id: "future".into(),
1275 edge_type: EdgeType::SectionReplace,
1276 role: EdgeRole::Secondary,
1277 ordinal: 1,
1278 source_field: "override_future_clip_id",
1279 }
1280 );
1281 }
1282
1283 #[test]
1284 fn resolve_roots_walks_a_connected_chain_with_no_http() {
1285 let http = ScriptedHttp::new();
1286 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1287 let clips = chain1_clips();
1288
1289 let roots = pollster::block_on(resolve_roots(
1290 &clips,
1291 &HashMap::new(),
1292 &mut client,
1293 &http,
1294 ResolveOpts::default(),
1295 ))
1296 .unwrap()
1297 .roots;
1298
1299 assert!(
1300 http.calls().is_empty(),
1301 "a fully-connected chain must never touch the network"
1302 );
1303 assert_eq!(roots.len(), clips.len());
1304 for clip in &clips {
1305 let info = &roots[&clip.id];
1306 assert_eq!(info.status, ResolveStatus::Resolved);
1307 assert_eq!(info.root_id, "dfb59a04");
1308 assert_eq!(info.root_title, "Zac and the Sea Eagles");
1309 }
1310 }
1311
1312 #[test]
1313 fn resolve_roots_gap_fills_a_missing_ancestor_by_id() {
1314 let cover = Clip {
1315 id: "child".into(),
1316 title: "Cover".into(),
1317 clip_type: "gen".into(),
1318 task: "cover".into(),
1319 cover_clip_id: "root".into(),
1320 edited_clip_id: "root".into(),
1321 ..Default::default()
1322 };
1323 let root_clip = serde_json::json!({
1324 "id": "root", "title": "Original", "status": "complete",
1325 "metadata": {"type": "gen"}
1326 })
1327 .to_string();
1328 let http = ScriptedHttp::new()
1329 .with_auth()
1330 .route("/api/clip/root", Reply::json(&root_clip));
1331 let mut client = authed_client(&http);
1332
1333 let roots = pollster::block_on(resolve_roots(
1334 &[cover],
1335 &HashMap::new(),
1336 &mut client,
1337 &http,
1338 ResolveOpts::default(),
1339 ))
1340 .unwrap()
1341 .roots;
1342
1343 let info = &roots["child"];
1344 assert_eq!(info.status, ResolveStatus::Resolved);
1345 assert_eq!(info.root_id, "root");
1346 assert_eq!(info.root_title, "Original");
1347 assert_eq!(http.count("/api/clip/root"), 1);
1348 assert_eq!(
1349 http.count("/api/clips/parent"),
1350 0,
1351 "the parent endpoint must not be used when the per-id fetch succeeds"
1352 );
1353 }
1354
1355 #[test]
1356 fn resolve_roots_hops_through_a_purged_ancestor_via_the_archive() {
1357 let child = Clip {
1363 id: "child".into(),
1364 title: "Neue Deutsche Harte".into(),
1365 clip_type: "gen".into(),
1366 task: "cover".into(),
1367 cover_clip_id: "mid".into(),
1368 edited_clip_id: "mid".into(),
1369 ..Default::default()
1370 };
1371 let root = Clip {
1372 id: "root".into(),
1373 title: "Original".into(),
1374 clip_type: "gen".into(),
1375 ..Default::default()
1376 };
1377 let archived: HashMap<String, String> = [("mid".to_owned(), "root".to_owned())]
1379 .into_iter()
1380 .collect();
1381 let http = ScriptedHttp::new().with_auth();
1382 let mut client = authed_client(&http);
1383
1384 let resolution = pollster::block_on(resolve_roots(
1385 &[child, root],
1386 &archived,
1387 &mut client,
1388 &http,
1389 ResolveOpts::default(),
1390 ))
1391 .unwrap();
1392
1393 let info = &resolution.roots["child"];
1394 assert_eq!(info.status, ResolveStatus::Resolved);
1395 assert_eq!(
1396 info.root_id, "root",
1397 "hopped through the purged intermediate"
1398 );
1399 assert_eq!(info.root_title, "Original");
1400 assert_eq!(
1401 http.count("/api/clip/mid"),
1402 0,
1403 "the purged intermediate is never fetched: the archived edge bridges it"
1404 );
1405 assert!(
1406 resolution.gap_filled.is_empty(),
1407 "an archived hop must not add a download candidate"
1408 );
1409 }
1410
1411 #[test]
1412 fn resolve_roots_prefers_a_live_pointer_over_a_stale_archived_edge() {
1413 let child = Clip {
1416 id: "child".into(),
1417 title: "Cover".into(),
1418 clip_type: "gen".into(),
1419 task: "cover".into(),
1420 cover_clip_id: "live_root".into(),
1421 edited_clip_id: "live_root".into(),
1422 ..Default::default()
1423 };
1424 let live_root = Clip {
1425 id: "live_root".into(),
1426 title: "Live Root".into(),
1427 clip_type: "gen".into(),
1428 ..Default::default()
1429 };
1430 let archived: HashMap<String, String> = [("child".to_owned(), "stale_root".to_owned())]
1431 .into_iter()
1432 .collect();
1433 let http = ScriptedHttp::new().with_auth();
1434 let mut client = authed_client(&http);
1435
1436 let info = pollster::block_on(resolve_roots(
1437 &[child, live_root],
1438 &archived,
1439 &mut client,
1440 &http,
1441 ResolveOpts::default(),
1442 ))
1443 .unwrap()
1444 .roots["child"]
1445 .clone();
1446
1447 assert_eq!(
1448 info.root_id, "live_root",
1449 "the live pointer wins over a stale archived edge"
1450 );
1451 assert_eq!(info.status, ResolveStatus::Resolved);
1452 }
1453
1454 #[test]
1455 fn resolve_roots_terminates_on_a_cycle_through_archived_edges() {
1456 let child = Clip {
1459 id: "child".into(),
1460 title: "Cover".into(),
1461 clip_type: "gen".into(),
1462 task: "cover".into(),
1463 cover_clip_id: "a".into(),
1464 edited_clip_id: "a".into(),
1465 ..Default::default()
1466 };
1467 let archived: HashMap<String, String> = [
1468 ("a".to_owned(), "b".to_owned()),
1469 ("b".to_owned(), "a".to_owned()),
1470 ]
1471 .into_iter()
1472 .collect();
1473 let http = ScriptedHttp::new().with_auth();
1474 let mut client = authed_client(&http);
1475
1476 let info = pollster::block_on(resolve_roots(
1477 &[child],
1478 &archived,
1479 &mut client,
1480 &http,
1481 ResolveOpts::default(),
1482 ))
1483 .unwrap()
1484 .roots["child"]
1485 .clone();
1486
1487 assert_eq!(
1488 info.status,
1489 ResolveStatus::Cycle,
1490 "an archived cycle terminates as a cycle, not an infinite loop"
1491 );
1492 }
1493
1494 #[test]
1495 fn resolve_roots_respects_the_hop_cap_through_archived_edges() {
1496 let child = Clip {
1499 id: "child".into(),
1500 title: "Cover".into(),
1501 clip_type: "gen".into(),
1502 task: "cover".into(),
1503 cover_clip_id: "a".into(),
1504 edited_clip_id: "a".into(),
1505 ..Default::default()
1506 };
1507 let archived: HashMap<String, String> = [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
1508 .iter()
1509 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1510 .collect();
1511 let opts = ResolveOpts {
1512 max_gap_fills: 0,
1513 hop_cap: 2,
1514 };
1515 let http = ScriptedHttp::new().with_auth();
1516 let mut client = authed_client(&http);
1517
1518 let info = pollster::block_on(resolve_roots(&[child], &archived, &mut client, &http, opts))
1519 .unwrap()
1520 .roots["child"]
1521 .clone();
1522
1523 assert_eq!(
1524 info.status,
1525 ResolveStatus::Unresolved,
1526 "a chain past the hop cap terminates as unresolved"
1527 );
1528 assert_eq!(
1529 http.count("/api/clip"),
1530 0,
1531 "archived hops need no clip fetch"
1532 );
1533 }
1534
1535 #[test]
1536 fn resolve_roots_without_archive_self_roots_a_purged_intermediate() {
1537 let child = Clip {
1542 id: "child".into(),
1543 title: "Neue Deutsche Harte".into(),
1544 clip_type: "gen".into(),
1545 task: "cover".into(),
1546 cover_clip_id: "mid".into(),
1547 edited_clip_id: "mid".into(),
1548 ..Default::default()
1549 };
1550 let root = Clip {
1551 id: "root".into(),
1552 title: "Original".into(),
1553 clip_type: "gen".into(),
1554 ..Default::default()
1555 };
1556 let http = ScriptedHttp::new()
1557 .with_auth()
1558 .route("/api/clip/mid", Reply::status(404))
1559 .route("/api/clips/parent", Reply::status(404));
1560 let mut client = authed_client(&http);
1561
1562 let info = pollster::block_on(resolve_roots(
1563 &[child, root],
1564 &HashMap::new(),
1565 &mut client,
1566 &http,
1567 ResolveOpts::default(),
1568 ))
1569 .unwrap()
1570 .roots["child"]
1571 .clone();
1572
1573 assert_ne!(
1574 info.root_id, "root",
1575 "without the archive, resolution cannot reach the true root"
1576 );
1577 assert_ne!(
1578 info.status,
1579 ResolveStatus::Resolved,
1580 "the purged intermediate cannot be cleanly resolved without the archive"
1581 );
1582 }
1583
1584 #[test]
1585 fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1586 let cover = Clip {
1590 id: "child".into(),
1591 title: "Cover".into(),
1592 clip_type: "gen".into(),
1593 task: "cover".into(),
1594 cover_clip_id: "root".into(),
1595 edited_clip_id: "root".into(),
1596 ..Default::default()
1597 };
1598 let root_clip = serde_json::json!({
1599 "id": "root", "title": "Trashed Original", "status": "complete",
1600 "metadata": {"type": "gen"}
1601 })
1602 .to_string();
1603 let http = ScriptedHttp::new()
1604 .with_auth()
1605 .route("/api/clip/root", Reply::json(&root_clip));
1606 let mut client = authed_client(&http);
1607
1608 let resolution = pollster::block_on(resolve_roots(
1609 &[cover],
1610 &HashMap::new(),
1611 &mut client,
1612 &http,
1613 ResolveOpts::default(),
1614 ))
1615 .unwrap();
1616
1617 assert_eq!(resolution.gap_filled.len(), 1);
1618 assert_eq!(resolution.gap_filled[0].id, "root");
1619 assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1620 assert_eq!(resolution.roots["child"].root_id, "root");
1621 assert!(
1622 !resolution.roots.contains_key("root"),
1623 "gap-filled ancestors must never enter the roots set"
1624 );
1625 }
1626
1627 #[test]
1628 fn resolve_roots_falls_back_to_the_parent_endpoint() {
1629 let cover = Clip {
1630 id: "child".into(),
1631 title: "Cover".into(),
1632 clip_type: "gen".into(),
1633 task: "cover".into(),
1634 cover_clip_id: "missing".into(),
1635 edited_clip_id: "missing".into(),
1636 ..Default::default()
1637 };
1638 let parent_body = serde_json::json!({
1641 "id": "root", "title": "Original", "status": "complete",
1642 "metadata": {"type": "gen"}
1643 })
1644 .to_string();
1645 let http = ScriptedHttp::new()
1646 .with_auth()
1647 .route("/api/clip/missing", Reply::status(404))
1648 .route("/api/clips/parent", Reply::json(&parent_body));
1649 let mut client = authed_client(&http);
1650
1651 let roots = pollster::block_on(resolve_roots(
1652 &[cover],
1653 &HashMap::new(),
1654 &mut client,
1655 &http,
1656 ResolveOpts::default(),
1657 ))
1658 .unwrap()
1659 .roots;
1660
1661 let info = &roots["child"];
1662 assert_eq!(info.status, ResolveStatus::Resolved);
1663 assert_eq!(info.root_id, "root");
1664 assert_eq!(info.root_title, "Original");
1665 assert!(
1666 http.count("/api/clips/parent?clip_id=missing") >= 1,
1667 "the missing ancestor must be resolved via the parent endpoint"
1668 );
1669 }
1670
1671 #[test]
1672 fn resolve_roots_detects_a_cycle_without_looping() {
1673 let a = Clip {
1674 id: "a".into(),
1675 title: "A".into(),
1676 clip_type: "gen".into(),
1677 task: "cover".into(),
1678 cover_clip_id: "b".into(),
1679 edited_clip_id: "b".into(),
1680 ..Default::default()
1681 };
1682 let b = Clip {
1683 id: "b".into(),
1684 title: "B".into(),
1685 clip_type: "gen".into(),
1686 task: "cover".into(),
1687 cover_clip_id: "a".into(),
1688 edited_clip_id: "a".into(),
1689 ..Default::default()
1690 };
1691 let http = ScriptedHttp::new();
1692 let mut client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1693
1694 let roots = pollster::block_on(resolve_roots(
1695 &[a, b],
1696 &HashMap::new(),
1697 &mut client,
1698 &http,
1699 ResolveOpts::default(),
1700 ))
1701 .unwrap()
1702 .roots;
1703
1704 assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1705 assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1706 assert!(http.calls().is_empty());
1707 }
1708
1709 #[test]
1710 fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1711 let child = Clip {
1713 id: "child".into(),
1714 title: "Child".into(),
1715 clip_type: "gen".into(),
1716 task: "cover".into(),
1717 cover_clip_id: "m1".into(),
1718 edited_clip_id: "m1".into(),
1719 ..Default::default()
1720 };
1721 let m1_clip = serde_json::json!({
1722 "id": "m1", "title": "Middle", "status": "complete",
1723 "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1724 })
1725 .to_string();
1726 let http = ScriptedHttp::new()
1727 .with_auth()
1728 .route("/api/clip/m1", Reply::json(&m1_clip));
1729 let mut client = authed_client(&http);
1730 let opts = ResolveOpts {
1731 max_gap_fills: 1,
1732 hop_cap: 64,
1733 };
1734
1735 let roots = pollster::block_on(resolve_roots(
1736 &[child],
1737 &HashMap::new(),
1738 &mut client,
1739 &http,
1740 opts,
1741 ))
1742 .unwrap()
1743 .roots;
1744
1745 let info = &roots["child"];
1746 assert_eq!(info.status, ResolveStatus::External);
1747 assert_eq!(
1748 info.root_id, "m2",
1749 "resolution stops at the first ancestor it could not fetch"
1750 );
1751 assert_eq!(http.count("/api/clip/m1"), 1);
1752 assert_eq!(
1753 http.count("/api/clip/m2"),
1754 0,
1755 "the gap-fill budget must not be exceeded"
1756 );
1757 }
1758
1759 #[test]
1760 fn resolve_roots_external_root_endpoint_stops_the_walk() {
1761 let cover = Clip {
1764 id: "child".into(),
1765 title: "Cover".into(),
1766 clip_type: "gen".into(),
1767 task: "cover".into(),
1768 cover_clip_id: "outside".into(),
1769 edited_clip_id: "outside".into(),
1770 ..Default::default()
1771 };
1772 let http = ScriptedHttp::new()
1773 .with_auth()
1774 .route("/api/clip/outside", Reply::status(404))
1775 .route("/api/clips/parent", Reply::status(404));
1776 let mut client = authed_client(&http);
1777
1778 let roots = pollster::block_on(resolve_roots(
1779 &[cover],
1780 &HashMap::new(),
1781 &mut client,
1782 &http,
1783 ResolveOpts::default(),
1784 ))
1785 .unwrap()
1786 .roots;
1787
1788 let info = &roots["child"];
1789 assert_eq!(info.status, ResolveStatus::External);
1790 assert_eq!(info.root_id, "outside");
1791 }
1792
1793 fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
1794 Resolution {
1795 roots: roots
1796 .into_iter()
1797 .map(|(id, info)| (id.to_owned(), info))
1798 .collect(),
1799 gap_filled: Vec::new(),
1800 bridges: Vec::new(),
1801 }
1802 }
1803
1804 #[test]
1805 fn context_for_a_root_uses_its_own_id_and_title() {
1806 let root = Clip {
1807 id: "root-1".into(),
1808 title: "Original".into(),
1809 ..Default::default()
1810 };
1811 let resolution = resolution_with(vec![(
1812 "root-1",
1813 RootInfo {
1814 root_id: "root-1".into(),
1815 root_title: "Original".into(),
1816 status: ResolveStatus::Resolved,
1817 },
1818 )]);
1819
1820 let ctx = LineageContext::for_clip(&root, &resolution);
1821 assert_eq!(ctx.root_id, "root-1");
1822 assert_eq!(ctx.root_title, "Original");
1823 assert_eq!(ctx.parent_id, "");
1824 assert_eq!(ctx.edge_type, None);
1825 assert_eq!(ctx.album("Original"), "Original");
1827 }
1828
1829 #[test]
1830 fn context_for_a_remix_carries_root_and_parent() {
1831 let child = Clip {
1832 id: "child-1".into(),
1833 title: "Remix".into(),
1834 clip_type: "gen".into(),
1835 task: "cover".into(),
1836 cover_clip_id: "root-1".into(),
1837 edited_clip_id: "root-1".into(),
1838 ..Default::default()
1839 };
1840 let resolution = resolution_with(vec![(
1841 "child-1",
1842 RootInfo {
1843 root_id: "root-1".into(),
1844 root_title: "Original".into(),
1845 status: ResolveStatus::Resolved,
1846 },
1847 )]);
1848
1849 let ctx = LineageContext::for_clip(&child, &resolution);
1850 assert_eq!(ctx.root_id, "root-1");
1851 assert_eq!(ctx.root_title, "Original");
1852 assert_eq!(ctx.parent_id, "root-1");
1853 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1854 assert_eq!(ctx.album("Remix"), "Original");
1856 }
1857
1858 #[test]
1859 fn context_absent_from_resolution_is_its_own_root() {
1860 let clip = Clip {
1861 id: "lonely".into(),
1862 title: "Solo".into(),
1863 ..Default::default()
1864 };
1865 let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
1866 assert_eq!(ctx.root_id, "lonely");
1867 assert_eq!(ctx.root_title, "Solo");
1868 assert_eq!(ctx.status, ResolveStatus::Resolved);
1869 assert_eq!(ctx.album("Solo"), "Solo");
1870 }
1871
1872 #[test]
1873 fn album_falls_back_to_own_title_when_root_title_is_empty() {
1874 let ctx = LineageContext {
1875 root_id: "outside".into(),
1876 root_title: String::new(),
1877 root_date: String::new(),
1878 parent_id: "outside".into(),
1879 edge_type: Some(EdgeType::Cover),
1880 status: ResolveStatus::External,
1881 };
1882 assert_eq!(ctx.album("My Title"), "My Title");
1883 }
1884
1885 #[test]
1886 fn own_root_has_no_parent() {
1887 let clip = Clip {
1888 id: "solo".into(),
1889 title: "Solo".into(),
1890 ..Default::default()
1891 };
1892 let ctx = LineageContext::own_root(&clip);
1893 assert_eq!(ctx.root_id, "solo");
1894 assert_eq!(ctx.parent_id, "");
1895 assert_eq!(ctx.edge_type, None);
1896 }
1897
1898 #[test]
1899 fn year_prefers_the_root_year_over_the_clips_own() {
1900 let ctx = LineageContext {
1903 root_id: "root-1".into(),
1904 root_title: "Origin".into(),
1905 root_date: "2023-12-30T23:00:00Z".into(),
1906 parent_id: "root-1".into(),
1907 edge_type: Some(EdgeType::Extend),
1908 status: ResolveStatus::Resolved,
1909 };
1910 assert_eq!(ctx.year("2024-01-02T08:00:00Z"), "2023");
1911 }
1912
1913 #[test]
1914 fn year_falls_back_to_own_when_the_root_date_is_unavailable() {
1915 let ctx = LineageContext {
1916 root_id: "outside".into(),
1917 root_title: String::new(),
1918 root_date: String::new(),
1919 parent_id: "outside".into(),
1920 edge_type: Some(EdgeType::Cover),
1921 status: ResolveStatus::External,
1922 };
1923 assert_eq!(ctx.year("2024-07-01T00:00:00Z"), "2024");
1924 }
1925
1926 #[test]
1927 fn own_root_tags_its_own_year() {
1928 let clip = Clip {
1929 id: "solo".into(),
1930 title: "Solo".into(),
1931 created_at: "2022-05-06T12:00:00Z".into(),
1932 ..Default::default()
1933 };
1934 let ctx = LineageContext::own_root(&clip);
1935 assert_eq!(ctx.root_date, "2022-05-06T12:00:00Z");
1936 assert_eq!(ctx.year(&clip.created_at), "2022");
1937 }
1938
1939 #[test]
1940 fn year_is_empty_when_no_date_is_known() {
1941 let clip = Clip::default();
1942 let ctx = LineageContext::own_root(&clip);
1943 assert_eq!(ctx.year(&clip.created_at), "");
1944 }
1945}