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