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