1use std::collections::{HashMap, HashSet};
17
18use serde::{Deserialize, Serialize};
19
20use crate::client::SunoClient;
21use crate::clock::Clock;
22use crate::error::Result;
23use crate::http::Http;
24use crate::model::Clip;
25
26const ZERO_UUID: &str = "00000000-0000-0000-0000-000000000000";
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum EdgeType {
32 Cover,
34 Remaster,
36 SpeedEdit,
38 Edit,
40 Extend,
42 SectionReplace,
44 Stitch,
46 Derived,
48 Uploaded,
50}
51
52impl EdgeType {
53 pub fn label(self) -> &'static str {
55 match self {
56 EdgeType::Cover => "Cover of",
57 EdgeType::Remaster => "Remaster of",
58 EdgeType::SpeedEdit => "Speed-edited from",
59 EdgeType::Edit => "Edited from",
60 EdgeType::Extend => "Extended from",
61 EdgeType::SectionReplace => "Section replaced from",
62 EdgeType::Stitch => "Stitched from",
63 EdgeType::Derived => "Derived from",
64 EdgeType::Uploaded => "Uploaded",
65 }
66 }
67}
68
69#[derive(
71 Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
72)]
73#[serde(rename_all = "snake_case")]
74pub enum EdgeRole {
75 #[default]
77 Primary,
78 Secondary,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct Edge {
85 pub parent_id: String,
87 pub edge_type: EdgeType,
89 pub role: EdgeRole,
91 pub ordinal: u32,
93 pub source_field: &'static str,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub struct ResolveOpts {
100 pub max_gap_fills: u32,
102 pub hop_cap: u32,
104 pub concurrency: u32,
106}
107
108impl Default for ResolveOpts {
109 fn default() -> Self {
110 Self {
111 max_gap_fills: 200,
112 hop_cap: 64,
113 concurrency: 4,
114 }
115 }
116}
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ResolveStatus {
122 External,
125 Unresolved,
127 Cycle,
129 #[default]
132 #[serde(other)]
133 Resolved,
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct RootInfo {
139 pub root_id: String,
141 pub root_title: String,
143 pub status: ResolveStatus,
145}
146
147#[derive(Debug, Clone, PartialEq)]
155pub struct Resolution {
156 pub roots: HashMap<String, RootInfo>,
159 pub gap_filled: Vec<Clip>,
162 pub bridges: Vec<(String, String)>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct LineageContext {
179 pub root_id: String,
181 pub root_title: String,
193 pub root_date: String,
206 pub parent_id: String,
208 pub edge_type: Option<EdgeType>,
210 pub status: ResolveStatus,
212}
213
214impl LineageContext {
215 pub fn for_clip(clip: &Clip, resolution: &Resolution) -> LineageContext {
224 let (root_id, root_title, status) = match resolution.roots.get(&clip.id) {
225 Some(info) => (info.root_id.clone(), info.root_title.clone(), info.status),
226 None => (clip.id.clone(), clip.title.clone(), ResolveStatus::Resolved),
227 };
228 let (parent_id, edge_type) = match immediate_parent(clip) {
229 Some((id, edge)) => (id, Some(edge)),
230 None => (String::new(), None),
231 };
232 LineageContext {
233 root_id,
234 root_title,
235 root_date: clip.created_at.clone(),
236 parent_id,
237 edge_type,
238 status,
239 }
240 }
241
242 pub fn own_root(clip: &Clip) -> LineageContext {
247 LineageContext {
248 root_id: clip.id.clone(),
249 root_title: clip.title.clone(),
250 root_date: clip.created_at.clone(),
251 parent_id: String::new(),
252 edge_type: None,
253 status: ResolveStatus::Resolved,
254 }
255 }
256
257 pub fn album(&self, own_title: &str) -> String {
264 let root_title = self.root_title.trim();
265 if !root_title.is_empty() && self.root_title != own_title {
266 self.root_title.clone()
267 } else {
268 own_title.to_owned()
269 }
270 }
271
272 pub fn year(&self, own_created_at: &str) -> String {
281 let root_year = year_of(&self.root_date);
282 if root_year.is_empty() {
283 year_of(own_created_at)
284 } else {
285 root_year
286 }
287 }
288}
289
290fn year_of(created_at: &str) -> String {
293 created_at.chars().take(4).collect()
294}
295
296pub fn edge_type(clip: &Clip) -> Option<EdgeType> {
309 let task = clip.task.as_str();
310 let clip_type = clip.clip_type.as_str();
311
312 if task == "infill" || task == "fixed_infill" {
313 Some(EdgeType::SectionReplace)
314 } else if task == "extend" {
315 Some(EdgeType::Extend)
316 } else if clip_type == "concat" {
317 Some(EdgeType::Stitch)
318 } else if clip_type == "edit_speed" {
319 Some(EdgeType::SpeedEdit)
320 } else if task == "cover" {
321 Some(EdgeType::Cover)
322 } else if clip_type == "upsample" || task == "upsample" {
323 Some(EdgeType::Remaster)
324 } else if clip_type == "edit_v3_export" {
325 Some(EdgeType::Edit)
326 } else if normalise_id(&clip.edited_clip_id).is_some() {
327 Some(EdgeType::Derived)
328 } else {
329 None
330 }
331}
332
333pub fn immediate_parent(clip: &Clip) -> Option<(String, EdgeType)> {
341 primary_parent(clip).map(|(id, edge, _field)| (id, edge))
342}
343
344pub fn lineage_edges(clip: &Clip) -> Vec<Edge> {
355 let Some(edge_type) = edge_type(clip) else {
356 return Vec::new();
357 };
358
359 let mut edges = Vec::new();
360 if let Some((parent_id, _edge, source_field)) = primary_parent(clip) {
361 edges.push(Edge {
362 parent_id,
363 edge_type,
364 role: EdgeRole::Primary,
365 ordinal: 0,
366 source_field,
367 });
368 }
369
370 match edge_type {
371 EdgeType::Stitch => {
372 for (ordinal, entry) in clip.concat_history.iter().enumerate().skip(1) {
373 if let Some(id) = normalise_id(&entry.id) {
374 edges.push(Edge {
375 parent_id: id,
376 edge_type,
377 role: EdgeRole::Secondary,
378 ordinal: ordinal as u32,
379 source_field: "concat_history",
380 });
381 }
382 }
383 }
384 EdgeType::SectionReplace => {
385 if let Some(future) = normalise_id(&clip.override_future_clip_id)
386 && edges
387 .first()
388 .is_none_or(|primary| primary.parent_id != future)
389 {
390 edges.push(Edge {
391 parent_id: future,
392 edge_type,
393 role: EdgeRole::Secondary,
394 ordinal: 1,
395 source_field: "override_future_clip_id",
396 });
397 }
398 }
399 _ => {}
400 }
401
402 edges
403}
404
405#[derive(Debug, Clone, PartialEq, Eq)]
416pub struct AttributionEdge {
417 pub parent_id: String,
419 pub edge_slug: String,
423 pub role: EdgeRole,
426 pub ordinal: u32,
428 pub source_field: &'static str,
430 pub same_owner: bool,
433}
434
435pub fn attribution_edges(clip: &Clip) -> Vec<AttributionEdge> {
441 let mut edges = Vec::new();
442 for root in &clip.clip_roots {
443 let Some(parent_id) = normalise_id(&root.id) else {
444 continue;
445 };
446 let ordinal = edges.len() as u32;
447 edges.push(AttributionEdge {
448 parent_id,
449 edge_slug: clip.clip_attribution_type.clone(),
450 role: EdgeRole::Secondary,
451 ordinal,
452 source_field: "clip_roots",
453 same_owner: same_owner(clip, root),
454 });
455 }
456 edges
457}
458
459fn same_owner(clip: &Clip, root: &crate::model::ClipRoot) -> bool {
466 let clip_handle = clip.handle.trim();
467 let root_handle = root.handle.trim();
468 !clip_handle.is_empty() && !root_handle.is_empty() && clip_handle == root_handle
469}
470
471pub async fn resolve_roots(
490 clips: &[Clip],
491 archived_parents: &HashMap<String, String>,
492 client: &SunoClient<impl Clock>,
493 http: &impl Http,
494 opts: ResolveOpts,
495) -> Result<Resolution> {
496 let mut resolver = Resolver::new(clips, opts, archived_parents);
497 resolver.run(client, http).await?;
498 Ok(resolver.into_resolution(clips))
499}
500
501fn primary_parent(clip: &Clip) -> Option<(String, EdgeType, &'static str)> {
505 let edge = edge_type(clip)?;
506 let history_head = clip.history.first().map_or("", |entry| entry.id.as_str());
507 let concat_head = clip
508 .concat_history
509 .first()
510 .map_or("", |entry| entry.id.as_str());
511
512 let candidates: Vec<(&str, &'static str)> = match edge {
513 EdgeType::SectionReplace => vec![
514 (
515 clip.override_history_clip_id.as_str(),
516 "override_history_clip_id",
517 ),
518 (
519 clip.override_future_clip_id.as_str(),
520 "override_future_clip_id",
521 ),
522 (history_head, "history"),
523 (clip.edited_clip_id.as_str(), "edited_clip_id"),
524 ],
525 EdgeType::Extend => vec![
526 (history_head, "history"),
527 (clip.edited_clip_id.as_str(), "edited_clip_id"),
528 ],
529 EdgeType::Stitch => vec![
530 (concat_head, "concat_history"),
531 (clip.edited_clip_id.as_str(), "edited_clip_id"),
532 ],
533 EdgeType::SpeedEdit => vec![
534 (clip.speed_clip_id.as_str(), "speed_clip_id"),
535 (clip.edited_clip_id.as_str(), "edited_clip_id"),
536 ],
537 EdgeType::Cover => vec![
538 (clip.cover_clip_id.as_str(), "cover_clip_id"),
539 (clip.edited_clip_id.as_str(), "edited_clip_id"),
540 ],
541 EdgeType::Remaster => vec![
542 (clip.upsample_clip_id.as_str(), "upsample_clip_id"),
543 (clip.remaster_clip_id.as_str(), "remaster_clip_id"),
544 (clip.edited_clip_id.as_str(), "edited_clip_id"),
545 ],
546 EdgeType::Edit | EdgeType::Derived => {
547 vec![(clip.edited_clip_id.as_str(), "edited_clip_id")]
548 }
549 EdgeType::Uploaded => vec![],
550 };
551
552 candidates
553 .into_iter()
554 .find_map(|(value, field)| normalise_id(value).map(|id| (id, edge, field)))
555}
556
557fn normalise_id(id: &str) -> Option<String> {
560 let id = id.strip_prefix("m_").unwrap_or(id);
561 if id.is_empty() || id == ZERO_UUID {
562 None
563 } else {
564 Some(id.to_string())
565 }
566}
567
568enum Walk {
570 Resolved,
572 Blocked(String),
574}
575
576struct Resolver<'a> {
584 index: HashMap<String, Clip>,
585 archived_parents: &'a HashMap<String, String>,
590 gap_filled: HashSet<String>,
591 bridges: HashMap<String, String>,
592 external: HashSet<String>,
593 seeded: HashSet<String>,
597 memo: HashMap<String, RootInfo>,
598 targets: Vec<String>,
599 budget: u32,
600 hop_cap: u32,
601 concurrency: u32,
602}
603
604impl<'a> Resolver<'a> {
605 fn new(
606 clips: &[Clip],
607 opts: ResolveOpts,
608 archived_parents: &'a HashMap<String, String>,
609 ) -> Self {
610 let index = clips
611 .iter()
612 .map(|clip| (clip.id.clone(), clip.clone()))
613 .collect();
614 let targets = clips.iter().map(|clip| clip.id.clone()).collect();
615 Self {
616 index,
617 archived_parents,
618 gap_filled: HashSet::new(),
619 bridges: HashMap::new(),
620 external: HashSet::new(),
621 seeded: HashSet::new(),
622 memo: HashMap::new(),
623 targets,
624 budget: opts.max_gap_fills,
625 hop_cap: opts.hop_cap,
626 concurrency: opts.concurrency,
627 }
628 }
629
630 async fn run(&mut self, client: &SunoClient<impl Clock>, http: &impl Http) -> Result<()> {
633 let targets = self.targets.clone();
634 loop {
635 let mut frontier: Vec<String> = Vec::new();
636 let mut seen: HashSet<String> = HashSet::new();
637 let mut blocked: Vec<(String, String)> = Vec::new();
638
639 for target in &targets {
640 if self.memo.contains_key(target) {
641 continue;
642 }
643 if let Walk::Blocked(missing) = self.walk(target) {
644 if seen.insert(missing.clone()) {
645 frontier.push(missing.clone());
646 }
647 blocked.push((target.clone(), missing));
648 }
649 }
650
651 if blocked.is_empty() {
652 break;
653 }
654 if self.budget == 0 || !self.gap_fill(client, http, &frontier).await? {
655 self.finalise_external(&blocked);
656 break;
657 }
658 }
659 Ok(())
660 }
661
662 fn walk(&mut self, start: &str) -> Walk {
666 if self.memo.contains_key(start) {
667 return Walk::Resolved;
668 }
669 let mut chain: Vec<String> = Vec::new();
670 let mut visited: HashSet<String> = HashSet::new();
671 let mut current = start.to_string();
672 let mut hops = 0u32;
673
674 loop {
675 if let Some(info) = self.memo.get(¤t).cloned() {
676 self.assign(&chain, &info);
677 return Walk::Resolved;
678 }
679 if visited.contains(¤t) {
680 let info = self.terminal(¤t, ResolveStatus::Cycle);
681 self.assign(&chain, &info);
682 self.memo.insert(current, info);
683 return Walk::Resolved;
684 }
685 if hops >= self.hop_cap {
686 let info = self.terminal(¤t, ResolveStatus::Unresolved);
687 self.assign(&chain, &info);
688 self.memo.insert(current, info);
689 return Walk::Resolved;
690 }
691
692 let parent_id = if let Some(clip) = self.index.get(¤t) {
699 immediate_parent(clip).map(|(id, _edge)| id)
700 } else if let Some(parent) = self.archived_parents.get(¤t) {
701 Some(parent.clone())
702 } else {
703 return Walk::Blocked(current);
704 };
705
706 let Some(parent_id) = parent_id else {
707 let info = RootInfo {
708 root_id: current.clone(),
709 root_title: self.title_of(¤t),
710 status: ResolveStatus::Resolved,
711 };
712 self.assign(&chain, &info);
713 self.memo.insert(current, info);
714 return Walk::Resolved;
715 };
716
717 visited.insert(current.clone());
718 chain.push(current);
719
720 if self.index.contains_key(&parent_id) || self.archived_parents.contains_key(&parent_id)
721 {
722 current = parent_id;
723 } else if let Some(bridged) = self.bridges.get(&parent_id).cloned() {
724 visited.insert(parent_id);
725 current = bridged;
726 } else if self.external.contains(&parent_id) {
727 let info = self.terminal(&parent_id, ResolveStatus::External);
728 self.assign(&chain, &info);
729 self.memo.insert(parent_id, info);
730 return Walk::Resolved;
731 } else {
732 return Walk::Blocked(parent_id);
733 }
734 hops += 1;
735 }
736 }
737
738 async fn gap_fill(
743 &mut self,
744 client: &SunoClient<impl Clock>,
745 http: &impl Http,
746 frontier: &[String],
747 ) -> Result<bool> {
748 let mut want: Vec<String> = frontier
752 .iter()
753 .filter(|id| !self.known(id))
754 .cloned()
755 .collect();
756 want.sort();
757 want.dedup();
758
759 let mut seeds: Vec<String> = self
766 .clip_root_seeds()
767 .into_iter()
768 .filter(|id| !self.known(id) && !self.seeded.contains(id) && !want.contains(id))
769 .collect();
770 seeds.sort();
771 seeds.dedup();
772
773 if want.is_empty() && seeds.is_empty() {
774 return Ok(false);
775 }
776
777 let frontier_take = (self.budget as usize).min(want.len());
780 let frontier_batch: Vec<String> = want.into_iter().take(frontier_take).collect();
781 self.budget -= frontier_batch.len() as u32;
782
783 let seed_take = (self.budget as usize).min(seeds.len());
784 let seed_batch: Vec<String> = seeds.into_iter().take(seed_take).collect();
785 self.budget -= seed_batch.len() as u32;
786 for id in &seed_batch {
787 self.seeded.insert(id.clone());
788 }
789
790 let all: Vec<&str> = frontier_batch
793 .iter()
794 .chain(seed_batch.iter())
795 .map(String::as_str)
796 .collect();
797 let fetched = client
798 .get_clips_by_ids(http, &all, self.concurrency as usize)
799 .await?;
800
801 let mut returned: HashSet<String> = HashSet::new();
802 let mut progressed = false;
803 for clip in fetched {
804 returned.insert(clip.id.clone());
805 if self.insert_ancestor(clip) {
806 progressed = true;
807 }
808 }
809
810 for id in &frontier_batch {
811 if returned.contains(id) {
812 continue;
813 }
814 match client.get_clip_parent(http, id).await? {
815 Some(parent) => {
816 let parent_id = parent.id.clone();
817 self.insert_ancestor(parent);
818 self.bridges.insert(id.clone(), parent_id);
819 progressed = true;
820 }
821 None => {
822 self.external.insert(id.clone());
823 progressed = true;
824 }
825 }
826 }
827
828 Ok(progressed)
829 }
830
831 fn clip_root_seeds(&self) -> Vec<String> {
835 let mut seeds = Vec::new();
836 for clip in self.index.values() {
837 for edge in attribution_edges(clip) {
838 if edge.same_owner {
839 seeds.push(edge.parent_id);
840 }
841 }
842 }
843 seeds
844 }
845
846 fn insert_ancestor(&mut self, clip: Clip) -> bool {
849 if clip.id.is_empty() || self.index.contains_key(&clip.id) {
850 return false;
851 }
852 self.gap_filled.insert(clip.id.clone());
853 self.index.insert(clip.id.clone(), clip);
854 true
855 }
856
857 fn known(&self, id: &str) -> bool {
859 self.index.contains_key(id)
860 || self.archived_parents.contains_key(id)
861 || self.bridges.contains_key(id)
862 || self.external.contains(id)
863 }
864
865 fn finalise_external(&mut self, blocked: &[(String, String)]) {
868 for (target, missing) in blocked {
869 if self.memo.contains_key(target) {
870 continue;
871 }
872 let info = self.terminal(missing, ResolveStatus::External);
873 self.memo.insert(target.clone(), info);
874 }
875 }
876
877 fn terminal(&self, id: &str, status: ResolveStatus) -> RootInfo {
879 RootInfo {
880 root_id: id.to_string(),
881 root_title: self.title_of(id),
882 status,
883 }
884 }
885
886 fn title_of(&self, id: &str) -> String {
888 self.index
889 .get(id)
890 .map_or_else(String::new, |clip| clip.title.clone())
891 }
892
893 fn assign(&mut self, chain: &[String], info: &RootInfo) {
895 for id in chain {
896 self.memo.insert(id.clone(), info.clone());
897 }
898 }
899
900 fn into_resolution(self, clips: &[Clip]) -> Resolution {
903 let mut roots = HashMap::with_capacity(clips.len());
904 for clip in clips {
905 let info = self
906 .memo
907 .get(&clip.id)
908 .cloned()
909 .unwrap_or_else(|| RootInfo {
910 root_id: clip.id.clone(),
911 root_title: clip.title.clone(),
912 status: ResolveStatus::Unresolved,
913 });
914 roots.insert(clip.id.clone(), info);
915 }
916
917 let mut gap_filled: Vec<Clip> = self
918 .gap_filled
919 .iter()
920 .filter_map(|id| self.index.get(id).cloned())
921 .collect();
922 gap_filled.sort_by(|a, b| a.id.cmp(&b.id));
923
924 let mut bridges: Vec<(String, String)> = self
925 .bridges
926 .iter()
927 .map(|(child, parent)| (child.clone(), parent.clone()))
928 .collect();
929 bridges.sort();
930
931 Resolution {
932 roots,
933 gap_filled,
934 bridges,
935 }
936 }
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use crate::auth::ClerkAuth;
943 use crate::model::HistoryEntry;
944 use crate::testutil::{RecordingClock, Reply, ScriptedHttp};
945
946 fn history(id: &str) -> HistoryEntry {
947 HistoryEntry {
948 id: id.to_owned(),
949 ..Default::default()
950 }
951 }
952
953 fn chain1_clips() -> Vec<Clip> {
957 vec![
958 Clip {
959 id: "40068b49".into(),
960 title: "Zac and the Sea Eagles (Lullaby Version)".into(),
961 clip_type: "upsample".into(),
962 task: "upsample".into(),
963 is_remix: true,
964 upsample_clip_id: "52962dae".into(),
965 edited_clip_id: "52962dae".into(),
966 ..Default::default()
967 },
968 Clip {
969 id: "52962dae".into(),
970 title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
971 clip_type: "gen".into(),
972 task: "cover".into(),
973 is_remix: true,
974 cover_clip_id: "536e1b92".into(),
975 edited_clip_id: "536e1b92".into(),
976 ..Default::default()
977 },
978 Clip {
979 id: "536e1b92".into(),
980 title: "Zac and the Sea Eagles (Edit) (Remastered)".into(),
981 clip_type: "upsample".into(),
982 task: "upsample".into(),
983 is_remix: true,
984 upsample_clip_id: "b9f27ee1".into(),
985 edited_clip_id: "b9f27ee1".into(),
986 ..Default::default()
987 },
988 Clip {
989 id: "b9f27ee1".into(),
990 title: "Zac and the Sea Eagles (Edit)".into(),
991 clip_type: "gen".into(),
992 task: "cover".into(),
993 is_remix: true,
994 cover_clip_id: "c1997d52".into(),
995 edited_clip_id: "c1997d52".into(),
996 ..Default::default()
997 },
998 Clip {
999 id: "c1997d52".into(),
1000 title: "Zac and the Sea Eagles (Rework)".into(),
1001 clip_type: "edit_v3_export".into(),
1002 edited_clip_id: "dfb59a04".into(),
1003 ..Default::default()
1004 },
1005 Clip {
1006 id: "dfb59a04".into(),
1007 title: "Zac and the Sea Eagles".into(),
1008 clip_type: "gen".into(),
1009 ..Default::default()
1010 },
1011 ]
1012 }
1013
1014 fn authed_client(http: &ScriptedHttp) -> SunoClient<RecordingClock> {
1015 let auth = ClerkAuth::new("eyJtoken");
1016 pollster::block_on(auth.authenticate(http)).unwrap();
1017 SunoClient::new(auth, RecordingClock::new())
1018 }
1019
1020 #[test]
1021 fn edge_type_labels_read_naturally() {
1022 assert_eq!(EdgeType::Cover.label(), "Cover of");
1023 assert_eq!(EdgeType::Remaster.label(), "Remaster of");
1024 assert_eq!(EdgeType::SpeedEdit.label(), "Speed-edited from");
1025 assert_eq!(EdgeType::Edit.label(), "Edited from");
1026 assert_eq!(EdgeType::Extend.label(), "Extended from");
1027 assert_eq!(EdgeType::SectionReplace.label(), "Section replaced from");
1028 assert_eq!(EdgeType::Stitch.label(), "Stitched from");
1029 assert_eq!(EdgeType::Derived.label(), "Derived from");
1030 assert_eq!(EdgeType::Uploaded.label(), "Uploaded");
1031 }
1032
1033 #[test]
1034 fn classifies_remaster_cover_edit_and_root_across_chain1() {
1035 let clips = chain1_clips();
1036
1037 assert_eq!(edge_type(&clips[0]), Some(EdgeType::Remaster));
1038 assert_eq!(
1039 immediate_parent(&clips[0]),
1040 Some(("52962dae".into(), EdgeType::Remaster))
1041 );
1042
1043 assert_eq!(edge_type(&clips[1]), Some(EdgeType::Cover));
1044 assert_eq!(
1045 immediate_parent(&clips[1]),
1046 Some(("536e1b92".into(), EdgeType::Cover))
1047 );
1048
1049 assert_eq!(edge_type(&clips[4]), Some(EdgeType::Edit));
1050 assert_eq!(
1051 immediate_parent(&clips[4]),
1052 Some(("dfb59a04".into(), EdgeType::Edit))
1053 );
1054
1055 assert_eq!(edge_type(&clips[5]), None);
1056 assert_eq!(immediate_parent(&clips[5]), None);
1057 }
1058
1059 #[test]
1060 fn classifies_speed_edit_from_speed_pointer_without_edited() {
1061 let clip = Clip {
1063 id: "6e5193b1".into(),
1064 title: "Go Xavi Go, Fast. (Drum n' Bass Version)".into(),
1065 clip_type: "edit_speed".into(),
1066 is_remix: true,
1067 speed_clip_id: "2b69882c".into(),
1068 ..Default::default()
1069 };
1070 assert_eq!(edge_type(&clip), Some(EdgeType::SpeedEdit));
1071 assert_eq!(
1072 immediate_parent(&clip),
1073 Some(("2b69882c".into(), EdgeType::SpeedEdit))
1074 );
1075 }
1076
1077 #[test]
1078 fn empty_task_gen_is_a_root() {
1079 let clip = Clip {
1081 id: "b4f16694".into(),
1082 title: "Go Xavi Go, Fast.".into(),
1083 clip_type: "gen".into(),
1084 task: String::new(),
1085 ..Default::default()
1086 };
1087 assert_eq!(edge_type(&clip), None);
1088 assert_eq!(immediate_parent(&clip), None);
1089 }
1090
1091 #[test]
1092 fn classifies_extend_from_history_head() {
1093 let clip = Clip {
1094 id: "9a3dcb67".into(),
1095 title: "Extended".into(),
1096 clip_type: "gen".into(),
1097 task: "extend".into(),
1098 edited_clip_id: "0a3c311a".into(),
1099 history: vec![HistoryEntry {
1100 id: "0a3c311a".into(),
1101 continue_at: Some(115.35),
1102 ..Default::default()
1103 }],
1104 ..Default::default()
1105 };
1106 assert_eq!(edge_type(&clip), Some(EdgeType::Extend));
1107 assert_eq!(
1108 immediate_parent(&clip),
1109 Some(("0a3c311a".into(), EdgeType::Extend))
1110 );
1111 }
1112
1113 #[test]
1114 fn classifies_infill_with_override_history_precedence() {
1115 let clip = Clip {
1117 id: "c0ce5c48".into(),
1118 title: "Section replaced".into(),
1119 clip_type: "gen".into(),
1120 task: "infill".into(),
1121 edited_clip_id: "cf37e05f".into(),
1122 override_history_clip_id: "d3d28e59".into(),
1123 override_future_clip_id: "ea88571e".into(),
1124 history: vec![HistoryEntry {
1125 id: "cf37e05f".into(),
1126 infill: true,
1127 infill_start_s: Some(20.4),
1128 infill_end_s: Some(24.92),
1129 ..Default::default()
1130 }],
1131 ..Default::default()
1132 };
1133 assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
1134 assert_eq!(
1135 immediate_parent(&clip),
1136 Some(("d3d28e59".into(), EdgeType::SectionReplace))
1137 );
1138 }
1139
1140 #[test]
1141 fn fixed_infill_is_also_section_replace() {
1142 let clip = Clip {
1143 task: "fixed_infill".into(),
1144 override_history_clip_id: "past".into(),
1145 edited_clip_id: "edited".into(),
1146 ..Default::default()
1147 };
1148 assert_eq!(edge_type(&clip), Some(EdgeType::SectionReplace));
1149 assert_eq!(
1150 immediate_parent(&clip),
1151 Some(("past".into(), EdgeType::SectionReplace))
1152 );
1153 }
1154
1155 #[test]
1156 fn classifies_stitch_from_concat_base() {
1157 let clip = Clip {
1159 id: "43ba1ce3".into(),
1160 title: "Stitched".into(),
1161 clip_type: "concat".into(),
1162 concat_history: vec![
1163 HistoryEntry {
1164 id: "ead64fbe".into(),
1165 continue_at: Some(149.19),
1166 ..Default::default()
1167 },
1168 history("da47b824"),
1169 ],
1170 ..Default::default()
1171 };
1172 assert_eq!(edge_type(&clip), Some(EdgeType::Stitch));
1173 assert_eq!(
1174 immediate_parent(&clip),
1175 Some(("ead64fbe".into(), EdgeType::Stitch))
1176 );
1177 }
1178
1179 #[test]
1180 fn inherited_concat_history_without_concat_type_is_not_a_stitch() {
1181 let clip = Clip {
1186 clip_type: "gen".into(),
1187 concat_history: vec![history("base"), history("second")],
1188 ..Default::default()
1189 };
1190 assert_eq!(edge_type(&clip), None);
1191 assert_eq!(immediate_parent(&clip), None);
1192 }
1193
1194 #[test]
1195 fn cover_of_a_stitch_classifies_as_cover_not_stitch() {
1196 let clip = Clip {
1200 id: "cov".into(),
1201 title: "Cover of a stitch".into(),
1202 clip_type: "gen".into(),
1203 task: "cover".into(),
1204 cover_clip_id: "stitch-parent".into(),
1205 edited_clip_id: "stitch-parent".into(),
1206 concat_history: vec![history("inherited-base"), history("inherited-seg")],
1207 ..Default::default()
1208 };
1209 assert_eq!(edge_type(&clip), Some(EdgeType::Cover));
1210 assert_eq!(
1211 immediate_parent(&clip),
1212 Some(("stitch-parent".into(), EdgeType::Cover))
1213 );
1214 }
1215
1216 #[test]
1217 fn upload_is_a_root() {
1218 let clip = Clip {
1219 id: "4770ef56".into(),
1220 title: "Uploaded audio".into(),
1221 clip_type: "upload".into(),
1222 ..Default::default()
1223 };
1224 assert_eq!(edge_type(&clip), None);
1225 assert_eq!(immediate_parent(&clip), None);
1226 }
1227
1228 #[test]
1229 fn edited_only_clip_is_derived() {
1230 let clip = Clip {
1232 clip_type: "gen".into(),
1233 task: "chop_sample_condition".into(),
1234 edited_clip_id: "parent-x".into(),
1235 ..Default::default()
1236 };
1237 assert_eq!(edge_type(&clip), Some(EdgeType::Derived));
1238 assert_eq!(
1239 immediate_parent(&clip),
1240 Some(("parent-x".into(), EdgeType::Derived))
1241 );
1242 }
1243
1244 #[test]
1245 fn unmarked_clip_without_pointer_is_a_root() {
1246 let clip = Clip {
1247 clip_type: "gen".into(),
1248 task: "chop_sample_condition".into(),
1249 ..Default::default()
1250 };
1251 assert_eq!(edge_type(&clip), None);
1252 assert_eq!(immediate_parent(&clip), None);
1253 }
1254
1255 #[test]
1256 fn is_remix_does_not_change_classification() {
1257 let base = Clip {
1258 clip_type: "gen".into(),
1259 task: "cover".into(),
1260 cover_clip_id: "root-1".into(),
1261 edited_clip_id: "root-1".into(),
1262 ..Default::default()
1263 };
1264 let mut with_flag = base.clone();
1265 with_flag.is_remix = true;
1266 let mut without_flag = base;
1267 without_flag.is_remix = false;
1268
1269 assert_eq!(edge_type(&with_flag), edge_type(&without_flag));
1270 assert_eq!(
1271 immediate_parent(&with_flag),
1272 immediate_parent(&without_flag)
1273 );
1274 assert_eq!(edge_type(&with_flag), Some(EdgeType::Cover));
1275 assert_eq!(
1276 immediate_parent(&with_flag),
1277 Some(("root-1".into(), EdgeType::Cover))
1278 );
1279 }
1280
1281 #[test]
1282 fn zero_uuid_cover_falls_back_to_edited() {
1283 let clip = Clip {
1284 clip_type: "gen".into(),
1285 task: "cover".into(),
1286 cover_clip_id: ZERO_UUID.into(),
1287 edited_clip_id: "real-parent".into(),
1288 ..Default::default()
1289 };
1290 assert_eq!(
1291 immediate_parent(&clip),
1292 Some(("real-parent".into(), EdgeType::Cover))
1293 );
1294 }
1295
1296 #[test]
1297 fn m_prefix_is_stripped_from_history_and_concat_ids() {
1298 let extend = Clip {
1299 clip_type: "gen".into(),
1300 task: "extend".into(),
1301 history: vec![history("m_abc123")],
1302 ..Default::default()
1303 };
1304 assert_eq!(
1305 immediate_parent(&extend),
1306 Some(("abc123".into(), EdgeType::Extend))
1307 );
1308
1309 let stitch = Clip {
1310 clip_type: "concat".into(),
1311 concat_history: vec![history("m_base"), history("m_second")],
1312 ..Default::default()
1313 };
1314 let edges = lineage_edges(&stitch);
1315 assert_eq!(edges[0].parent_id, "base");
1316 assert_eq!(edges[1].parent_id, "second");
1317 assert_eq!(edges[1].role, EdgeRole::Secondary);
1318 }
1319
1320 #[test]
1321 fn lineage_edges_of_a_root_is_empty() {
1322 let clip = Clip {
1323 clip_type: "gen".into(),
1324 ..Default::default()
1325 };
1326 assert!(lineage_edges(&clip).is_empty());
1327 }
1328
1329 #[test]
1330 fn lineage_edges_records_stitch_secondaries_in_order() {
1331 let clip = Clip {
1332 clip_type: "concat".into(),
1333 concat_history: vec![history("base"), history("seg1"), history("seg2")],
1334 ..Default::default()
1335 };
1336 let edges = lineage_edges(&clip);
1337 assert_eq!(
1338 edges,
1339 vec![
1340 Edge {
1341 parent_id: "base".into(),
1342 edge_type: EdgeType::Stitch,
1343 role: EdgeRole::Primary,
1344 ordinal: 0,
1345 source_field: "concat_history",
1346 },
1347 Edge {
1348 parent_id: "seg1".into(),
1349 edge_type: EdgeType::Stitch,
1350 role: EdgeRole::Secondary,
1351 ordinal: 1,
1352 source_field: "concat_history",
1353 },
1354 Edge {
1355 parent_id: "seg2".into(),
1356 edge_type: EdgeType::Stitch,
1357 role: EdgeRole::Secondary,
1358 ordinal: 2,
1359 source_field: "concat_history",
1360 },
1361 ]
1362 );
1363 }
1364
1365 #[test]
1366 fn lineage_edges_emits_secondaries_when_the_primary_is_absent() {
1367 let clip = Clip {
1371 clip_type: "concat".into(),
1372 concat_history: vec![history(""), history("seg1"), history("seg2")],
1373 ..Default::default()
1374 };
1375 let edges = lineage_edges(&clip);
1376 assert_eq!(
1377 edges,
1378 vec![
1379 Edge {
1380 parent_id: "seg1".into(),
1381 edge_type: EdgeType::Stitch,
1382 role: EdgeRole::Secondary,
1383 ordinal: 1,
1384 source_field: "concat_history",
1385 },
1386 Edge {
1387 parent_id: "seg2".into(),
1388 edge_type: EdgeType::Stitch,
1389 role: EdgeRole::Secondary,
1390 ordinal: 2,
1391 source_field: "concat_history",
1392 },
1393 ],
1394 "secondaries survive an empty primary base segment"
1395 );
1396 }
1397
1398 #[test]
1399 fn lineage_edges_records_infill_future_as_secondary() {
1400 let clip = Clip {
1401 task: "infill".into(),
1402 override_history_clip_id: "past".into(),
1403 override_future_clip_id: "future".into(),
1404 ..Default::default()
1405 };
1406 let edges = lineage_edges(&clip);
1407 assert_eq!(edges[0].parent_id, "past");
1408 assert_eq!(edges[0].role, EdgeRole::Primary);
1409 assert_eq!(edges[0].source_field, "override_history_clip_id");
1410 assert_eq!(
1411 edges[1],
1412 Edge {
1413 parent_id: "future".into(),
1414 edge_type: EdgeType::SectionReplace,
1415 role: EdgeRole::Secondary,
1416 ordinal: 1,
1417 source_field: "override_future_clip_id",
1418 }
1419 );
1420 }
1421
1422 #[test]
1423 fn resolve_roots_walks_a_connected_chain_with_no_http() {
1424 let http = ScriptedHttp::new();
1425 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1426 let clips = chain1_clips();
1427
1428 let roots = pollster::block_on(resolve_roots(
1429 &clips,
1430 &HashMap::new(),
1431 &client,
1432 &http,
1433 ResolveOpts::default(),
1434 ))
1435 .unwrap()
1436 .roots;
1437
1438 assert!(
1439 http.calls().is_empty(),
1440 "a fully-connected chain must never touch the network"
1441 );
1442 assert_eq!(roots.len(), clips.len());
1443 for clip in &clips {
1444 let info = &roots[&clip.id];
1445 assert_eq!(info.status, ResolveStatus::Resolved);
1446 assert_eq!(info.root_id, "dfb59a04");
1447 assert_eq!(info.root_title, "Zac and the Sea Eagles");
1448 }
1449 }
1450
1451 #[test]
1452 fn resolve_roots_gap_fills_a_missing_ancestor_by_id() {
1453 let cover = Clip {
1454 id: "child".into(),
1455 title: "Cover".into(),
1456 clip_type: "gen".into(),
1457 task: "cover".into(),
1458 cover_clip_id: "root".into(),
1459 edited_clip_id: "root".into(),
1460 ..Default::default()
1461 };
1462 let root_clip = serde_json::json!({
1463 "id": "root", "title": "Original", "status": "complete",
1464 "metadata": {"type": "gen"}
1465 })
1466 .to_string();
1467 let http = ScriptedHttp::new()
1468 .with_auth()
1469 .route("/api/clip/root", Reply::json(&root_clip));
1470 let client = authed_client(&http);
1471
1472 let roots = pollster::block_on(resolve_roots(
1473 &[cover],
1474 &HashMap::new(),
1475 &client,
1476 &http,
1477 ResolveOpts::default(),
1478 ))
1479 .unwrap()
1480 .roots;
1481
1482 let info = &roots["child"];
1483 assert_eq!(info.status, ResolveStatus::Resolved);
1484 assert_eq!(info.root_id, "root");
1485 assert_eq!(info.root_title, "Original");
1486 assert_eq!(http.count("/api/clip/root"), 1);
1487 assert_eq!(
1488 http.count("/api/clips/parent"),
1489 0,
1490 "the parent endpoint must not be used when the per-id fetch succeeds"
1491 );
1492 }
1493
1494 #[test]
1495 fn resolve_roots_hops_through_a_purged_ancestor_via_the_archive() {
1496 let child = Clip {
1502 id: "child".into(),
1503 title: "Neue Deutsche Harte".into(),
1504 clip_type: "gen".into(),
1505 task: "cover".into(),
1506 cover_clip_id: "mid".into(),
1507 edited_clip_id: "mid".into(),
1508 ..Default::default()
1509 };
1510 let root = Clip {
1511 id: "root".into(),
1512 title: "Original".into(),
1513 clip_type: "gen".into(),
1514 ..Default::default()
1515 };
1516 let archived: HashMap<String, String> = [("mid".to_owned(), "root".to_owned())]
1518 .into_iter()
1519 .collect();
1520 let http = ScriptedHttp::new().with_auth();
1521 let client = authed_client(&http);
1522
1523 let resolution = pollster::block_on(resolve_roots(
1524 &[child, root],
1525 &archived,
1526 &client,
1527 &http,
1528 ResolveOpts::default(),
1529 ))
1530 .unwrap();
1531
1532 let info = &resolution.roots["child"];
1533 assert_eq!(info.status, ResolveStatus::Resolved);
1534 assert_eq!(
1535 info.root_id, "root",
1536 "hopped through the purged intermediate"
1537 );
1538 assert_eq!(info.root_title, "Original");
1539 assert_eq!(
1540 http.count("/api/clip/mid"),
1541 0,
1542 "the purged intermediate is never fetched: the archived edge bridges it"
1543 );
1544 assert!(
1545 resolution.gap_filled.is_empty(),
1546 "an archived hop must not add a download candidate"
1547 );
1548 }
1549
1550 #[test]
1551 fn resolve_roots_prefers_a_live_pointer_over_a_stale_archived_edge() {
1552 let child = Clip {
1555 id: "child".into(),
1556 title: "Cover".into(),
1557 clip_type: "gen".into(),
1558 task: "cover".into(),
1559 cover_clip_id: "live_root".into(),
1560 edited_clip_id: "live_root".into(),
1561 ..Default::default()
1562 };
1563 let live_root = Clip {
1564 id: "live_root".into(),
1565 title: "Live Root".into(),
1566 clip_type: "gen".into(),
1567 ..Default::default()
1568 };
1569 let archived: HashMap<String, String> = [("child".to_owned(), "stale_root".to_owned())]
1570 .into_iter()
1571 .collect();
1572 let http = ScriptedHttp::new().with_auth();
1573 let client = authed_client(&http);
1574
1575 let info = pollster::block_on(resolve_roots(
1576 &[child, live_root],
1577 &archived,
1578 &client,
1579 &http,
1580 ResolveOpts::default(),
1581 ))
1582 .unwrap()
1583 .roots["child"]
1584 .clone();
1585
1586 assert_eq!(
1587 info.root_id, "live_root",
1588 "the live pointer wins over a stale archived edge"
1589 );
1590 assert_eq!(info.status, ResolveStatus::Resolved);
1591 }
1592
1593 #[test]
1594 fn resolve_roots_terminates_on_a_cycle_through_archived_edges() {
1595 let child = Clip {
1598 id: "child".into(),
1599 title: "Cover".into(),
1600 clip_type: "gen".into(),
1601 task: "cover".into(),
1602 cover_clip_id: "a".into(),
1603 edited_clip_id: "a".into(),
1604 ..Default::default()
1605 };
1606 let archived: HashMap<String, String> = [
1607 ("a".to_owned(), "b".to_owned()),
1608 ("b".to_owned(), "a".to_owned()),
1609 ]
1610 .into_iter()
1611 .collect();
1612 let http = ScriptedHttp::new().with_auth();
1613 let client = authed_client(&http);
1614
1615 let info = pollster::block_on(resolve_roots(
1616 &[child],
1617 &archived,
1618 &client,
1619 &http,
1620 ResolveOpts::default(),
1621 ))
1622 .unwrap()
1623 .roots["child"]
1624 .clone();
1625
1626 assert_eq!(
1627 info.status,
1628 ResolveStatus::Cycle,
1629 "an archived cycle terminates as a cycle, not an infinite loop"
1630 );
1631 }
1632
1633 #[test]
1634 fn resolve_roots_respects_the_hop_cap_through_archived_edges() {
1635 let child = Clip {
1638 id: "child".into(),
1639 title: "Cover".into(),
1640 clip_type: "gen".into(),
1641 task: "cover".into(),
1642 cover_clip_id: "a".into(),
1643 edited_clip_id: "a".into(),
1644 ..Default::default()
1645 };
1646 let archived: HashMap<String, String> = [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")]
1647 .iter()
1648 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1649 .collect();
1650 let opts = ResolveOpts {
1651 max_gap_fills: 0,
1652 hop_cap: 2,
1653 concurrency: 4,
1654 };
1655 let http = ScriptedHttp::new().with_auth();
1656 let client = authed_client(&http);
1657
1658 let info = pollster::block_on(resolve_roots(&[child], &archived, &client, &http, opts))
1659 .unwrap()
1660 .roots["child"]
1661 .clone();
1662
1663 assert_eq!(
1664 info.status,
1665 ResolveStatus::Unresolved,
1666 "a chain past the hop cap terminates as unresolved"
1667 );
1668 assert_eq!(
1669 http.count("/api/clip"),
1670 0,
1671 "archived hops need no clip fetch"
1672 );
1673 }
1674
1675 #[test]
1676 fn resolve_roots_without_archive_self_roots_a_purged_intermediate() {
1677 let child = Clip {
1682 id: "child".into(),
1683 title: "Neue Deutsche Harte".into(),
1684 clip_type: "gen".into(),
1685 task: "cover".into(),
1686 cover_clip_id: "mid".into(),
1687 edited_clip_id: "mid".into(),
1688 ..Default::default()
1689 };
1690 let root = Clip {
1691 id: "root".into(),
1692 title: "Original".into(),
1693 clip_type: "gen".into(),
1694 ..Default::default()
1695 };
1696 let http = ScriptedHttp::new()
1697 .with_auth()
1698 .route("/api/clip/mid", Reply::status(404))
1699 .route("/api/clips/parent", Reply::status(404));
1700 let client = authed_client(&http);
1701
1702 let info = pollster::block_on(resolve_roots(
1703 &[child, root],
1704 &HashMap::new(),
1705 &client,
1706 &http,
1707 ResolveOpts::default(),
1708 ))
1709 .unwrap()
1710 .roots["child"]
1711 .clone();
1712
1713 assert_ne!(
1714 info.root_id, "root",
1715 "without the archive, resolution cannot reach the true root"
1716 );
1717 assert_ne!(
1718 info.status,
1719 ResolveStatus::Resolved,
1720 "the purged intermediate cannot be cleanly resolved without the archive"
1721 );
1722 }
1723
1724 #[test]
1725 fn resolve_roots_returns_gap_filled_ancestors_for_archival() {
1726 let cover = Clip {
1730 id: "child".into(),
1731 title: "Cover".into(),
1732 clip_type: "gen".into(),
1733 task: "cover".into(),
1734 cover_clip_id: "root".into(),
1735 edited_clip_id: "root".into(),
1736 ..Default::default()
1737 };
1738 let root_clip = serde_json::json!({
1739 "id": "root", "title": "Trashed Original", "status": "complete",
1740 "metadata": {"type": "gen"}
1741 })
1742 .to_string();
1743 let http = ScriptedHttp::new()
1744 .with_auth()
1745 .route("/api/clip/root", Reply::json(&root_clip));
1746 let client = authed_client(&http);
1747
1748 let resolution = pollster::block_on(resolve_roots(
1749 &[cover],
1750 &HashMap::new(),
1751 &client,
1752 &http,
1753 ResolveOpts::default(),
1754 ))
1755 .unwrap();
1756
1757 assert_eq!(resolution.gap_filled.len(), 1);
1758 assert_eq!(resolution.gap_filled[0].id, "root");
1759 assert_eq!(resolution.gap_filled[0].title, "Trashed Original");
1760 assert_eq!(resolution.roots["child"].root_id, "root");
1761 assert!(
1762 !resolution.roots.contains_key("root"),
1763 "gap-filled ancestors must never enter the roots set"
1764 );
1765 }
1766
1767 #[test]
1768 fn resolve_roots_falls_back_to_the_parent_endpoint() {
1769 let cover = Clip {
1770 id: "child".into(),
1771 title: "Cover".into(),
1772 clip_type: "gen".into(),
1773 task: "cover".into(),
1774 cover_clip_id: "missing".into(),
1775 edited_clip_id: "missing".into(),
1776 ..Default::default()
1777 };
1778 let parent_body = serde_json::json!({
1781 "id": "root", "title": "Original", "status": "complete",
1782 "metadata": {"type": "gen"}
1783 })
1784 .to_string();
1785 let http = ScriptedHttp::new()
1786 .with_auth()
1787 .route("/api/clip/missing", Reply::status(404))
1788 .route("/api/clips/parent", Reply::json(&parent_body));
1789 let client = authed_client(&http);
1790
1791 let roots = pollster::block_on(resolve_roots(
1792 &[cover],
1793 &HashMap::new(),
1794 &client,
1795 &http,
1796 ResolveOpts::default(),
1797 ))
1798 .unwrap()
1799 .roots;
1800
1801 let info = &roots["child"];
1802 assert_eq!(info.status, ResolveStatus::Resolved);
1803 assert_eq!(info.root_id, "root");
1804 assert_eq!(info.root_title, "Original");
1805 assert!(
1806 http.count("/api/clips/parent?clip_id=missing") >= 1,
1807 "the missing ancestor must be resolved via the parent endpoint"
1808 );
1809 }
1810
1811 #[test]
1812 fn resolve_roots_detects_a_cycle_without_looping() {
1813 let a = Clip {
1814 id: "a".into(),
1815 title: "A".into(),
1816 clip_type: "gen".into(),
1817 task: "cover".into(),
1818 cover_clip_id: "b".into(),
1819 edited_clip_id: "b".into(),
1820 ..Default::default()
1821 };
1822 let b = Clip {
1823 id: "b".into(),
1824 title: "B".into(),
1825 clip_type: "gen".into(),
1826 task: "cover".into(),
1827 cover_clip_id: "a".into(),
1828 edited_clip_id: "a".into(),
1829 ..Default::default()
1830 };
1831 let http = ScriptedHttp::new();
1832 let client = SunoClient::new(ClerkAuth::new("eyJtoken"), RecordingClock::new());
1833
1834 let roots = pollster::block_on(resolve_roots(
1835 &[a, b],
1836 &HashMap::new(),
1837 &client,
1838 &http,
1839 ResolveOpts::default(),
1840 ))
1841 .unwrap()
1842 .roots;
1843
1844 assert_eq!(roots["a"].status, ResolveStatus::Cycle);
1845 assert_eq!(roots["b"].status, ResolveStatus::Cycle);
1846 assert!(http.calls().is_empty());
1847 }
1848
1849 #[test]
1850 fn resolve_roots_marks_external_when_the_budget_is_exhausted() {
1851 let child = Clip {
1853 id: "child".into(),
1854 title: "Child".into(),
1855 clip_type: "gen".into(),
1856 task: "cover".into(),
1857 cover_clip_id: "m1".into(),
1858 edited_clip_id: "m1".into(),
1859 ..Default::default()
1860 };
1861 let m1_clip = serde_json::json!({
1862 "id": "m1", "title": "Middle", "status": "complete",
1863 "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "m2", "edited_clip_id": "m2"}
1864 })
1865 .to_string();
1866 let http = ScriptedHttp::new()
1867 .with_auth()
1868 .route("/api/clip/m1", Reply::json(&m1_clip));
1869 let client = authed_client(&http);
1870 let opts = ResolveOpts {
1871 max_gap_fills: 1,
1872 hop_cap: 64,
1873 concurrency: 4,
1874 };
1875
1876 let roots = pollster::block_on(resolve_roots(
1877 &[child],
1878 &HashMap::new(),
1879 &client,
1880 &http,
1881 opts,
1882 ))
1883 .unwrap()
1884 .roots;
1885
1886 let info = &roots["child"];
1887 assert_eq!(info.status, ResolveStatus::External);
1888 assert_eq!(
1889 info.root_id, "m2",
1890 "resolution stops at the first ancestor it could not fetch"
1891 );
1892 assert_eq!(http.count("/api/clip/m1"), 1);
1893 assert_eq!(
1894 http.count("/api/clip/m2"),
1895 0,
1896 "the gap-fill budget must not be exceeded"
1897 );
1898 }
1899
1900 #[test]
1901 fn resolve_roots_external_root_endpoint_stops_the_walk() {
1902 let cover = Clip {
1905 id: "child".into(),
1906 title: "Cover".into(),
1907 clip_type: "gen".into(),
1908 task: "cover".into(),
1909 cover_clip_id: "outside".into(),
1910 edited_clip_id: "outside".into(),
1911 ..Default::default()
1912 };
1913 let http = ScriptedHttp::new()
1914 .with_auth()
1915 .route("/api/clip/outside", Reply::status(404))
1916 .route("/api/clips/parent", Reply::status(404));
1917 let client = authed_client(&http);
1918
1919 let roots = pollster::block_on(resolve_roots(
1920 &[cover],
1921 &HashMap::new(),
1922 &client,
1923 &http,
1924 ResolveOpts::default(),
1925 ))
1926 .unwrap()
1927 .roots;
1928
1929 let info = &roots["child"];
1930 assert_eq!(info.status, ResolveStatus::External);
1931 assert_eq!(info.root_id, "outside");
1932 }
1933
1934 fn clip_root(id: &str, handle: &str) -> crate::model::ClipRoot {
1935 crate::model::ClipRoot {
1936 id: id.to_owned(),
1937 handle: handle.to_owned(),
1938 ..Default::default()
1939 }
1940 }
1941
1942 #[test]
1943 fn attribution_edges_map_clip_roots_in_order() {
1944 let clip = Clip {
1945 id: "child".into(),
1946 handle: "me".into(),
1947 clip_attribution_type: "remix".into(),
1948 clip_roots: vec![
1949 clip_root("own-root", "me"),
1950 clip_root("foreign-root", "stranger"),
1951 ],
1952 ..Default::default()
1953 };
1954 let edges = attribution_edges(&clip);
1955 assert_eq!(edges.len(), 2);
1956 assert_eq!(
1957 edges[0],
1958 AttributionEdge {
1959 parent_id: "own-root".into(),
1960 edge_slug: "remix".into(),
1961 role: EdgeRole::Secondary,
1962 ordinal: 0,
1963 source_field: "clip_roots",
1964 same_owner: true,
1965 }
1966 );
1967 assert_eq!(edges[1].parent_id, "foreign-root");
1968 assert_eq!(edges[1].ordinal, 1);
1969 assert!(
1970 !edges[1].same_owner,
1971 "a differently-handled root is foreign, and still emits an edge"
1972 );
1973 }
1974
1975 #[test]
1976 fn attribution_edges_are_empty_without_clip_roots() {
1977 let clip = Clip {
1978 id: "child".into(),
1979 handle: "me".into(),
1980 ..Default::default()
1981 };
1982 assert!(attribution_edges(&clip).is_empty());
1983 }
1984
1985 #[test]
1986 fn attribution_edges_same_owner_is_fail_closed() {
1987 let matched = Clip {
1990 handle: "me".into(),
1991 clip_roots: vec![clip_root("r", "me")],
1992 ..Default::default()
1993 };
1994 assert!(attribution_edges(&matched)[0].same_owner);
1995
1996 let clip_blank = Clip {
1997 handle: "".into(),
1998 clip_roots: vec![clip_root("r", "me")],
1999 ..Default::default()
2000 };
2001 assert!(
2002 !attribution_edges(&clip_blank)[0].same_owner,
2003 "an empty clip handle is fail-closed to foreign"
2004 );
2005
2006 let root_blank = Clip {
2007 handle: "me".into(),
2008 clip_roots: vec![clip_root("r", " ")],
2009 ..Default::default()
2010 };
2011 assert!(
2012 !attribution_edges(&root_blank)[0].same_owner,
2013 "a whitespace-only root handle is fail-closed to foreign"
2014 );
2015 }
2016
2017 #[test]
2018 fn attribution_edges_skip_a_root_with_no_id_and_keep_contiguous_ordinals() {
2019 let clip = Clip {
2020 handle: "me".into(),
2021 clip_attribution_type: "remix".into(),
2022 clip_roots: vec![
2023 clip_root("", "me"),
2024 clip_root(ZERO_UUID, "me"),
2025 clip_root("real-root", "me"),
2026 ],
2027 ..Default::default()
2028 };
2029 let edges = attribution_edges(&clip);
2030 assert_eq!(edges.len(), 1, "empty and sentinel root ids are dropped");
2031 assert_eq!(edges[0].parent_id, "real-root");
2032 assert_eq!(edges[0].ordinal, 0, "ordinals stay contiguous after a skip");
2033 }
2034
2035 #[test]
2036 fn resolve_roots_seeds_a_same_owner_clip_root_but_not_a_foreign_one() {
2037 let child = Clip {
2042 id: "child".into(),
2043 title: "Remix".into(),
2044 clip_type: "gen".into(),
2045 task: "cover".into(),
2046 cover_clip_id: "struct-parent".into(),
2047 edited_clip_id: "struct-parent".into(),
2048 handle: "me".into(),
2049 clip_attribution_type: "remix".into(),
2050 clip_roots: vec![
2051 clip_root("own-root", "me"),
2052 clip_root("foreign-root", "stranger"),
2053 ],
2054 ..Default::default()
2055 };
2056 let struct_parent = serde_json::json!({
2057 "id": "struct-parent", "title": "Structural Root", "status": "complete",
2058 "metadata": {"type": "gen"}
2059 })
2060 .to_string();
2061 let own_root = serde_json::json!({
2062 "id": "own-root", "title": "Attribution Root", "status": "complete",
2063 "metadata": {"type": "gen"}
2064 })
2065 .to_string();
2066 let batch = format!(r#"{{"clips":[{struct_parent},{own_root}]}}"#);
2069 let http = ScriptedHttp::new()
2070 .with_auth()
2071 .route("get_songs_by_ids", Reply::json(&batch))
2072 .route("/api/clip/struct-parent", Reply::json(&struct_parent))
2073 .route("/api/clip/own-root", Reply::json(&own_root));
2074 let client = authed_client(&http);
2075
2076 let resolution = pollster::block_on(resolve_roots(
2077 &[child],
2078 &HashMap::new(),
2079 &client,
2080 &http,
2081 ResolveOpts::default(),
2082 ))
2083 .unwrap();
2084
2085 let info = &resolution.roots["child"];
2087 assert_eq!(info.status, ResolveStatus::Resolved);
2088 assert_eq!(info.root_id, "struct-parent");
2089
2090 assert_eq!(
2091 http.count("own-root"),
2092 1,
2093 "the same-owner clip_root is seeded and fetched exactly once"
2094 );
2095 assert_eq!(
2096 http.count("foreign-root"),
2097 0,
2098 "a foreign-owned clip_root is NEVER seeded or fetched"
2099 );
2100 }
2101
2102 #[test]
2103 fn resolve_roots_clip_root_seed_is_best_effort_never_bridges_or_retries() {
2104 let child = Clip {
2108 id: "child".into(),
2109 title: "Remix".into(),
2110 clip_type: "gen".into(),
2111 task: "cover".into(),
2112 cover_clip_id: "mid".into(),
2113 edited_clip_id: "mid".into(),
2114 handle: "me".into(),
2115 clip_attribution_type: "remix".into(),
2116 clip_roots: vec![clip_root("gone-root", "me")],
2117 ..Default::default()
2118 };
2119 let mid = serde_json::json!({
2122 "id": "mid", "title": "Mid", "status": "complete",
2123 "metadata": {"type": "gen", "task": "cover", "cover_clip_id": "root"}
2124 })
2125 .to_string();
2126 let root = serde_json::json!({
2127 "id": "root", "title": "Root", "status": "complete",
2128 "metadata": {"type": "gen"}
2129 })
2130 .to_string();
2131 let http = ScriptedHttp::new()
2132 .with_auth()
2133 .route("/api/clip/mid", Reply::json(&mid))
2134 .route("/api/clip/root", Reply::json(&root))
2135 .route("/api/clip/gone-root", Reply::status(404));
2136 let client = authed_client(&http);
2137
2138 let resolution = pollster::block_on(resolve_roots(
2139 &[child],
2140 &HashMap::new(),
2141 &client,
2142 &http,
2143 ResolveOpts::default(),
2144 ))
2145 .unwrap();
2146
2147 let info = &resolution.roots["child"];
2148 assert_eq!(info.status, ResolveStatus::Resolved);
2149 assert_eq!(
2150 info.root_id, "root",
2151 "the structural chain resolves normally"
2152 );
2153 assert!(
2154 resolution.bridges.is_empty(),
2155 "a dropped seed must never become a bridge"
2156 );
2157 assert!(
2158 !resolution.gap_filled.iter().any(|c| c.id == "gone-root"),
2159 "a seed the batch omits is never added"
2160 );
2161 assert_eq!(
2162 http.count("/api/clip/gone-root"),
2163 1,
2164 "the seed is attempted once, never retried across rounds"
2165 );
2166 assert_eq!(
2167 http.count("/api/clips/parent"),
2168 0,
2169 "a seed never falls through to the parent endpoint"
2170 );
2171 }
2172
2173 fn resolution_with(roots: Vec<(&str, RootInfo)>) -> Resolution {
2174 Resolution {
2175 roots: roots
2176 .into_iter()
2177 .map(|(id, info)| (id.to_owned(), info))
2178 .collect(),
2179 gap_filled: Vec::new(),
2180 bridges: Vec::new(),
2181 }
2182 }
2183
2184 #[test]
2185 fn context_for_a_root_uses_its_own_id_and_title() {
2186 let root = Clip {
2187 id: "root-1".into(),
2188 title: "Original".into(),
2189 ..Default::default()
2190 };
2191 let resolution = resolution_with(vec![(
2192 "root-1",
2193 RootInfo {
2194 root_id: "root-1".into(),
2195 root_title: "Original".into(),
2196 status: ResolveStatus::Resolved,
2197 },
2198 )]);
2199
2200 let ctx = LineageContext::for_clip(&root, &resolution);
2201 assert_eq!(ctx.root_id, "root-1");
2202 assert_eq!(ctx.root_title, "Original");
2203 assert_eq!(ctx.parent_id, "");
2204 assert_eq!(ctx.edge_type, None);
2205 assert_eq!(ctx.album("Original"), "Original");
2207 }
2208
2209 #[test]
2210 fn context_for_a_remix_carries_root_and_parent() {
2211 let child = Clip {
2212 id: "child-1".into(),
2213 title: "Remix".into(),
2214 clip_type: "gen".into(),
2215 task: "cover".into(),
2216 cover_clip_id: "root-1".into(),
2217 edited_clip_id: "root-1".into(),
2218 ..Default::default()
2219 };
2220 let resolution = resolution_with(vec![(
2221 "child-1",
2222 RootInfo {
2223 root_id: "root-1".into(),
2224 root_title: "Original".into(),
2225 status: ResolveStatus::Resolved,
2226 },
2227 )]);
2228
2229 let ctx = LineageContext::for_clip(&child, &resolution);
2230 assert_eq!(ctx.root_id, "root-1");
2231 assert_eq!(ctx.root_title, "Original");
2232 assert_eq!(ctx.parent_id, "root-1");
2233 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
2234 assert_eq!(ctx.album("Remix"), "Original");
2236 }
2237
2238 #[test]
2239 fn context_absent_from_resolution_is_its_own_root() {
2240 let clip = Clip {
2241 id: "lonely".into(),
2242 title: "Solo".into(),
2243 ..Default::default()
2244 };
2245 let ctx = LineageContext::for_clip(&clip, &resolution_with(vec![]));
2246 assert_eq!(ctx.root_id, "lonely");
2247 assert_eq!(ctx.root_title, "Solo");
2248 assert_eq!(ctx.status, ResolveStatus::Resolved);
2249 assert_eq!(ctx.album("Solo"), "Solo");
2250 }
2251
2252 #[test]
2253 fn album_falls_back_to_own_title_when_root_title_is_empty() {
2254 let ctx = LineageContext {
2255 root_id: "outside".into(),
2256 root_title: String::new(),
2257 root_date: String::new(),
2258 parent_id: "outside".into(),
2259 edge_type: Some(EdgeType::Cover),
2260 status: ResolveStatus::External,
2261 };
2262 assert_eq!(ctx.album("My Title"), "My Title");
2263 }
2264
2265 #[test]
2266 fn own_root_has_no_parent() {
2267 let clip = Clip {
2268 id: "solo".into(),
2269 title: "Solo".into(),
2270 ..Default::default()
2271 };
2272 let ctx = LineageContext::own_root(&clip);
2273 assert_eq!(ctx.root_id, "solo");
2274 assert_eq!(ctx.parent_id, "");
2275 assert_eq!(ctx.edge_type, None);
2276 }
2277
2278 #[test]
2279 fn year_prefers_the_root_year_over_the_clips_own() {
2280 let ctx = LineageContext {
2283 root_id: "root-1".into(),
2284 root_title: "Origin".into(),
2285 root_date: "2023-12-30T23:00:00Z".into(),
2286 parent_id: "root-1".into(),
2287 edge_type: Some(EdgeType::Extend),
2288 status: ResolveStatus::Resolved,
2289 };
2290 assert_eq!(ctx.year("2024-01-02T08:00:00Z"), "2023");
2291 }
2292
2293 #[test]
2294 fn year_falls_back_to_own_when_the_root_date_is_unavailable() {
2295 let ctx = LineageContext {
2296 root_id: "outside".into(),
2297 root_title: String::new(),
2298 root_date: String::new(),
2299 parent_id: "outside".into(),
2300 edge_type: Some(EdgeType::Cover),
2301 status: ResolveStatus::External,
2302 };
2303 assert_eq!(ctx.year("2024-07-01T00:00:00Z"), "2024");
2304 }
2305
2306 #[test]
2307 fn own_root_tags_its_own_year() {
2308 let clip = Clip {
2309 id: "solo".into(),
2310 title: "Solo".into(),
2311 created_at: "2022-05-06T12:00:00Z".into(),
2312 ..Default::default()
2313 };
2314 let ctx = LineageContext::own_root(&clip);
2315 assert_eq!(ctx.root_date, "2022-05-06T12:00:00Z");
2316 assert_eq!(ctx.year(&clip.created_at), "2022");
2317 }
2318
2319 #[test]
2320 fn year_is_empty_when_no_date_is_known() {
2321 let clip = Clip::default();
2322 let ctx = LineageContext::own_root(&clip);
2323 assert_eq!(ctx.year(&clip.created_at), "");
2324 }
2325}