1use std::collections::btree_map::Iter;
20use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
21
22use serde::{Deserialize, Serialize};
23
24use crate::lineage::{
25 Edge, EdgeRole, EdgeType, LineageContext, Resolution, ResolveStatus, RootInfo,
26 immediate_parent, lineage_edges,
27};
28use crate::manifest::ArtifactState;
29use crate::model::Clip;
30use crate::reconcile::ArtifactKind;
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LineageStore {
39 pub schema_version: u32,
41 pub(crate) nodes: BTreeMap<String, Node>,
43 pub(crate) edges: Vec<StoredEdge>,
45 pub(crate) resolution_cache: BTreeMap<String, CacheEntry>,
47 pub albums: BTreeMap<String, AlbumArt>,
50 pub playlists: BTreeMap<String, PlaylistState>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub owner: Option<Owner>,
58 #[serde(skip)]
65 pub album_overrides: BTreeMap<String, String>,
66 #[serde(skip)]
80 eligible_root_ids: HashSet<String>,
81 #[serde(skip)]
85 edge_index: HashMap<EdgeKey, usize>,
86}
87
88impl Default for LineageStore {
89 fn default() -> Self {
90 Self {
91 schema_version: 1,
92 nodes: BTreeMap::new(),
93 edges: Vec::new(),
94 resolution_cache: BTreeMap::new(),
95 albums: BTreeMap::new(),
96 playlists: BTreeMap::new(),
97 owner: None,
98 album_overrides: BTreeMap::new(),
99 eligible_root_ids: HashSet::new(),
100 edge_index: HashMap::new(),
101 }
102 }
103}
104
105impl PartialEq for LineageStore {
114 fn eq(&self, other: &Self) -> bool {
115 self.schema_version == other.schema_version
116 && self.nodes == other.nodes
117 && self.edges == other.edges
118 && self.resolution_cache == other.resolution_cache
119 && self.albums == other.albums
120 && self.playlists == other.playlists
121 && self.owner == other.owner
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Owner {
132 pub user_id: String,
133 pub display_name: String,
134}
135
136#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum OwnerGate {
145 AbortConfigMismatch,
148 AbortMismatch,
150 Repin,
153 Proceed,
156 FirstUse,
158}
159
160impl OwnerGate {
161 pub fn is_additive(self) -> bool {
163 matches!(self, OwnerGate::Repin)
164 }
165}
166
167pub fn owner_gate(
174 store_owner: Option<&Owner>,
175 configured_id: Option<&str>,
176 authed_user_id: &str,
177 allow_change: bool,
178) -> OwnerGate {
179 if let Some(configured) = configured_id
180 && configured != authed_user_id
181 {
182 return OwnerGate::AbortConfigMismatch;
183 }
184 match store_owner {
185 None => OwnerGate::FirstUse,
186 Some(owner) if owner.user_id == authed_user_id => OwnerGate::Proceed,
187 Some(_) if allow_change => OwnerGate::Repin,
188 Some(_) => OwnerGate::AbortMismatch,
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum AdoptDecision {
199 PinFresh,
202 PinAdopt,
205 AdoptForced,
208 Abort,
211 SkipPin,
213}
214
215impl AdoptDecision {
216 pub fn is_additive(self) -> bool {
218 matches!(self, AdoptDecision::AdoptForced)
219 }
220}
221
222pub fn adopt_decision(
232 listed: &[&str],
233 owned: &BTreeSet<&str>,
234 enumerated: bool,
235 allow_change: bool,
236) -> AdoptDecision {
237 if owned.is_empty() {
238 return AdoptDecision::PinFresh;
239 }
240 if !enumerated {
241 return AdoptDecision::SkipPin;
242 }
243 if listed.iter().any(|id| owned.contains(id)) {
244 AdoptDecision::PinAdopt
245 } else if allow_change {
246 AdoptDecision::AdoptForced
247 } else {
248 AdoptDecision::Abort
249 }
250}
251
252#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(default)]
262pub struct AlbumArt {
263 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub folder_jpg: Option<ArtifactState>,
266 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub folder_webp: Option<ArtifactState>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub folder_mp4: Option<ArtifactState>,
273}
274
275impl AlbumArt {
276 pub fn artifact(&self, kind: ArtifactKind) -> Option<&ArtifactState> {
279 match kind {
280 ArtifactKind::FolderJpg => self.folder_jpg.as_ref(),
281 ArtifactKind::FolderWebp => self.folder_webp.as_ref(),
282 ArtifactKind::FolderMp4 => self.folder_mp4.as_ref(),
283 ArtifactKind::CoverJpg
284 | ArtifactKind::CoverWebp
285 | ArtifactKind::DetailsTxt
286 | ArtifactKind::LyricsTxt
287 | ArtifactKind::Lrc
288 | ArtifactKind::VideoMp4
289 | ArtifactKind::Playlist => None,
290 }
291 }
292
293 pub fn set(&mut self, kind: ArtifactKind, state: Option<ArtifactState>) {
299 match kind {
300 ArtifactKind::FolderJpg => self.folder_jpg = state,
301 ArtifactKind::FolderWebp => self.folder_webp = state,
302 ArtifactKind::FolderMp4 => self.folder_mp4 = state,
303 ArtifactKind::CoverJpg
304 | ArtifactKind::CoverWebp
305 | ArtifactKind::DetailsTxt
306 | ArtifactKind::LyricsTxt
307 | ArtifactKind::Lrc
308 | ArtifactKind::VideoMp4
309 | ArtifactKind::Playlist => {}
310 }
311 }
312
313 pub fn is_empty(&self) -> bool {
316 self.folder_jpg.is_none() && self.folder_webp.is_none() && self.folder_mp4.is_none()
317 }
318}
319
320#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
329#[serde(default)]
330pub struct PlaylistState {
331 pub name: String,
333 pub path: String,
335 pub hash: String,
337}
338
339#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "snake_case")]
342pub enum NodeStatus {
343 #[default]
344 #[serde(other)]
345 Observed,
346}
347
348#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
350#[serde(rename_all = "snake_case")]
351pub enum EdgeStatus {
352 #[default]
353 #[serde(other)]
354 Active,
355}
356
357#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
360#[serde(default)]
361pub struct Node {
362 pub title: String,
363 pub created_at: String,
364 pub clip_type: String,
365 pub task: String,
366 pub is_remix: bool,
367 pub is_trashed: bool,
368 pub status: NodeStatus,
369 pub first_seen_at: String,
370 pub last_seen_at: String,
371}
372
373impl Default for Node {
374 fn default() -> Self {
375 Self {
376 title: String::new(),
377 created_at: String::new(),
378 clip_type: String::new(),
379 task: String::new(),
380 is_remix: false,
381 is_trashed: false,
382 status: NodeStatus::Observed,
383 first_seen_at: String::new(),
384 last_seen_at: String::new(),
385 }
386 }
387}
388
389#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
393#[serde(default)]
394pub struct StoredEdge {
395 pub child_id: String,
396 pub parent_id: String,
397 pub edge_type: String,
399 pub role: EdgeRole,
400 pub source_field: String,
402 pub ordinal: u32,
404 pub status: EdgeStatus,
405 pub first_seen_at: String,
406 pub last_seen_at: String,
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Hash)]
410struct EdgeKey {
411 child_id: String,
412 parent_id: String,
413 edge_type: String,
414 role: EdgeRole,
415 ordinal: u32,
416}
417
418impl EdgeKey {
419 fn new(child_id: &str, parent_id: &str, edge_type: &str, role: EdgeRole, ordinal: u32) -> Self {
420 Self {
421 child_id: child_id.to_owned(),
422 parent_id: parent_id.to_owned(),
423 edge_type: edge_type.to_owned(),
424 role,
425 ordinal,
426 }
427 }
428
429 fn from_stored(edge: &StoredEdge) -> Self {
430 Self::new(
431 &edge.child_id,
432 &edge.parent_id,
433 &edge.edge_type,
434 edge.role,
435 edge.ordinal,
436 )
437 }
438}
439
440impl Default for StoredEdge {
441 fn default() -> Self {
442 Self {
443 child_id: String::new(),
444 parent_id: String::new(),
445 edge_type: String::new(),
446 role: EdgeRole::Primary,
447 source_field: String::new(),
448 ordinal: 0,
449 status: EdgeStatus::Active,
450 first_seen_at: String::new(),
451 last_seen_at: String::new(),
452 }
453 }
454}
455
456#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
458#[serde(default)]
459pub struct CacheEntry {
460 pub root_id: String,
461 pub status: ResolveStatus,
462 pub algorithm_version: u32,
463 pub computed_at: String,
464}
465
466impl LineageStore {
467 pub fn new() -> Self {
469 Self::default()
470 }
471
472 pub fn set_album_overrides(&mut self, overrides: BTreeMap<String, String>) {
485 self.album_overrides = overrides;
486 }
487
488 fn effective_root_title(&self, root_id: &str, root_title: String) -> String {
512 if !self.eligible_root_ids.contains(root_id) {
513 return root_title;
514 }
515 match self.album_overrides.get(root_id) {
516 Some(name) if !name.trim().is_empty() => name.clone(),
517 _ => root_title,
518 }
519 }
520
521 pub fn refresh_eligible_roots(&mut self) {
531 self.eligible_root_ids = self
532 .resolution_cache
533 .values()
534 .map(|entry| entry.root_id.as_str())
535 .filter(|root_id| !root_id.is_empty())
536 .map(str::to_owned)
537 .collect();
538 }
539
540 #[cfg(test)]
543 pub(crate) fn eligible_root_ids_for_test(&self) -> &HashSet<String> {
544 &self.eligible_root_ids
545 }
546
547 pub fn node(&self, id: &str) -> Option<&Node> {
549 self.nodes.get(id)
550 }
551
552 pub fn owner(&self) -> Option<&Owner> {
554 self.owner.as_ref()
555 }
556
557 pub fn pin_owner(&mut self, owner: Owner) {
559 self.owner = Some(owner);
560 }
561
562 pub fn refresh_display_name(&mut self, display_name: &str) -> bool {
565 match &mut self.owner {
566 Some(owner) if owner.display_name != display_name => {
567 owner.display_name = display_name.to_owned();
568 true
569 }
570 _ => false,
571 }
572 }
573
574 pub fn get_root(&self, id: &str) -> Option<&CacheEntry> {
576 self.resolution_cache.get(id)
577 }
578
579 pub fn album_art(&self, root_id: &str) -> Option<&AlbumArt> {
581 self.albums.get(root_id)
582 }
583
584 pub fn set_album_artifact(
592 &mut self,
593 root_id: &str,
594 kind: ArtifactKind,
595 state: Option<ArtifactState>,
596 ) {
597 match state {
598 Some(state) => self
599 .albums
600 .entry(root_id.to_owned())
601 .or_default()
602 .set(kind, Some(state)),
603 None => {
604 if let Some(art) = self.albums.get_mut(root_id) {
605 art.set(kind, None);
606 if art.is_empty() {
607 self.albums.remove(root_id);
608 }
609 }
610 }
611 }
612 }
613
614 pub fn playlist(&self, id: &str) -> Option<&PlaylistState> {
616 self.playlists.get(id)
617 }
618
619 pub fn set_playlist(&mut self, id: &str, state: Option<PlaylistState>) {
627 match state {
628 Some(state) => {
629 self.playlists.insert(id.to_owned(), state);
630 }
631 None => {
632 self.playlists.remove(id);
633 }
634 }
635 }
636
637 pub fn context_for(&self, clip: &Clip) -> LineageContext {
648 let cached = self.get_root(&clip.id);
649 let root_id = cached
650 .map(|entry| entry.root_id.clone())
651 .filter(|id| !id.is_empty())
652 .unwrap_or_else(|| clip.id.clone());
653 let root_title = self
654 .node(&root_id)
655 .map(|node| node.title.clone())
656 .unwrap_or_else(|| clip.title.clone());
657 let root_title = self.effective_root_title(&root_id, root_title);
658 let root_date = self
659 .node(&root_id)
660 .map(|node| node.created_at.clone())
661 .unwrap_or_else(|| clip.created_at.clone());
662 let (parent_id, edge_type) = match immediate_parent(clip) {
663 Some((id, edge)) => (id, Some(edge)),
664 None => (String::new(), None),
665 };
666 let status = cached
667 .map(|entry| entry.status)
668 .unwrap_or(ResolveStatus::Resolved);
669 LineageContext {
670 root_id,
671 root_title,
672 root_date,
673 parent_id,
674 edge_type,
675 status,
676 }
677 }
678
679 pub fn album_for_id(&self, id: &str) -> String {
688 let own = self.node(id);
689 let own_title = own.map(|node| node.title.clone()).unwrap_or_default();
690 let own_created_at = own.map(|node| node.created_at.clone()).unwrap_or_default();
691 let root_id = self
692 .get_root(id)
693 .map(|entry| entry.root_id.clone())
694 .filter(|root| !root.is_empty())
695 .unwrap_or_else(|| id.to_owned());
696 let root_title = self
697 .node(&root_id)
698 .map(|node| node.title.clone())
699 .unwrap_or_else(|| own_title.clone());
700 let root_title = self.effective_root_title(&root_id, root_title);
701 let root_date = self
702 .node(&root_id)
703 .map(|node| node.created_at.clone())
704 .unwrap_or(own_created_at);
705 let context = LineageContext {
706 root_id,
707 root_title,
708 root_date,
709 parent_id: String::new(),
710 edge_type: None,
711 status: ResolveStatus::Resolved,
712 };
713 context.album(&own_title)
714 }
715
716 pub fn colliding_root_titles(&self) -> BTreeSet<String> {
742 let mut roots_by_title: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
743 for root_id in &self.eligible_root_ids {
744 let node_title = self
745 .nodes
746 .get(root_id)
747 .map(|node| node.title.clone())
748 .unwrap_or_default();
749 let effective = self.effective_root_title(root_id, node_title);
750 let title = effective.trim();
751 if title.is_empty() {
752 continue;
753 }
754 roots_by_title
755 .entry(title.to_owned())
756 .or_default()
757 .insert(root_id.clone());
758 }
759 roots_by_title
760 .into_iter()
761 .filter(|(_, roots)| roots.len() > 1)
762 .map(|(title, _)| title)
763 .collect()
764 }
765
766 pub fn len(&self) -> usize {
768 self.nodes.len()
769 }
770
771 pub fn is_empty(&self) -> bool {
773 self.nodes.is_empty()
774 }
775
776 pub fn iter(&self) -> Iter<'_, String, Node> {
778 self.nodes.iter()
779 }
780
781 pub fn update(&mut self, clips: &[Clip], resolution: &Resolution, now: &str) {
789 self.rebuild_edge_index();
790
791 for clip in clips {
792 self.upsert_node(clip, now);
793 }
794 for clip in &resolution.gap_filled {
797 self.upsert_node(clip, now);
798 }
799
800 for clip in clips.iter().chain(resolution.gap_filled.iter()) {
808 for edge in lineage_edges(clip) {
809 self.upsert_edge(&clip.id, &edge, now);
810 }
811 }
812 for (child_id, parent_id) in &resolution.bridges {
813 let edge = Edge {
814 parent_id: parent_id.clone(),
815 edge_type: EdgeType::Derived,
816 role: EdgeRole::Primary,
817 ordinal: 0,
818 source_field: "parent_endpoint",
819 };
820 self.upsert_edge(child_id, &edge, now);
821 }
822 self.edges.sort_by(|a, b| {
823 a.child_id
824 .cmp(&b.child_id)
825 .then(a.ordinal.cmp(&b.ordinal))
826 .then(a.parent_id.cmp(&b.parent_id))
827 .then(a.edge_type.cmp(&b.edge_type))
828 .then(a.role.cmp(&b.role))
829 });
830 self.rebuild_edge_index();
831
832 for (child_id, info) in &resolution.roots {
833 self.upsert_cache(child_id, info, now);
834 }
835 self.refresh_eligible_roots();
836 }
837
838 pub fn archived_parents(&self) -> HashMap<String, String> {
847 self.edges
848 .iter()
849 .filter(|edge| {
850 edge.role == EdgeRole::Primary
851 && edge.ordinal == 0
852 && edge.status == EdgeStatus::Active
853 })
854 .map(|edge| (edge.child_id.clone(), edge.parent_id.clone()))
855 .collect()
856 }
857
858 fn upsert_node(&mut self, clip: &Clip, now: &str) {
861 let node = self.nodes.entry(clip.id.clone()).or_insert_with(|| Node {
862 first_seen_at: now.to_owned(),
863 ..Node::default()
864 });
865 node.title = clip.title.clone();
866 node.created_at = clip.created_at.clone();
867 node.clip_type = clip.clip_type.clone();
868 node.task = clip.task.clone();
869 node.is_remix = clip.is_remix;
870 node.is_trashed = clip.is_trashed;
871 node.last_seen_at = now.to_owned();
872 }
873
874 fn upsert_edge(&mut self, child_id: &str, edge: &Edge, now: &str) {
877 let edge_type = edge_type_slug(edge.edge_type);
878 let key = EdgeKey::new(
879 child_id,
880 &edge.parent_id,
881 edge_type,
882 edge.role,
883 edge.ordinal,
884 );
885 if let Some(&index) = self.edge_index.get(&key) {
886 let existing = &mut self.edges[index];
887 existing.source_field = edge.source_field.to_owned();
888 existing.status = EdgeStatus::Active;
889 existing.last_seen_at = now.to_owned();
890 } else {
891 self.edges.push(StoredEdge {
892 child_id: child_id.to_owned(),
893 parent_id: edge.parent_id.clone(),
894 edge_type: edge_type.to_owned(),
895 role: edge.role,
896 source_field: edge.source_field.to_owned(),
897 ordinal: edge.ordinal,
898 status: EdgeStatus::Active,
899 first_seen_at: now.to_owned(),
900 last_seen_at: now.to_owned(),
901 });
902 self.edge_index.insert(key, self.edges.len() - 1);
903 }
904 }
905
906 fn rebuild_edge_index(&mut self) {
907 self.edge_index.clear();
908 for (index, edge) in self.edges.iter().enumerate() {
909 self.edge_index
910 .entry(EdgeKey::from_stored(edge))
911 .or_insert(index);
912 }
913 }
914
915 fn upsert_cache(&mut self, child_id: &str, info: &RootInfo, now: &str) {
922 if info.status != ResolveStatus::Resolved
923 && self
924 .resolution_cache
925 .get(child_id)
926 .is_some_and(|entry| entry.status == ResolveStatus::Resolved)
927 {
928 return;
929 }
930 self.resolution_cache.insert(
931 child_id.to_owned(),
932 CacheEntry {
933 root_id: info.root_id.clone(),
934 status: info.status,
935 algorithm_version: 1,
936 computed_at: now.to_owned(),
937 },
938 );
939 }
940}
941
942fn edge_type_slug(edge_type: EdgeType) -> &'static str {
944 match edge_type {
945 EdgeType::Cover => "cover",
946 EdgeType::Remaster => "remaster",
947 EdgeType::SpeedEdit => "speed_edit",
948 EdgeType::Edit => "edit",
949 EdgeType::Extend => "extend",
950 EdgeType::SectionReplace => "section_replace",
951 EdgeType::Stitch => "stitch",
952 EdgeType::Derived => "derived",
953 EdgeType::Uploaded => "uploaded",
954 }
955}
956
957#[cfg(test)]
958mod tests {
959 use super::*;
960 use std::collections::HashMap;
961
962 fn chain_clips() -> Vec<Clip> {
964 vec![
965 Clip {
966 id: "c".into(),
967 title: "Cover".into(),
968 clip_type: "gen".into(),
969 task: "cover".into(),
970 created_at: "t2".into(),
971 cover_clip_id: "b".into(),
972 edited_clip_id: "b".into(),
973 ..Default::default()
974 },
975 Clip {
976 id: "b".into(),
977 title: "Remaster".into(),
978 clip_type: "upsample".into(),
979 task: "upsample".into(),
980 created_at: "t1".into(),
981 upsample_clip_id: "a".into(),
982 edited_clip_id: "a".into(),
983 ..Default::default()
984 },
985 Clip {
986 id: "a".into(),
987 title: "Root".into(),
988 clip_type: "gen".into(),
989 created_at: "t0".into(),
990 ..Default::default()
991 },
992 ]
993 }
994
995 fn chain_resolution() -> Resolution {
997 let mut roots = HashMap::new();
998 for id in ["a", "b", "c"] {
999 roots.insert(
1000 id.to_owned(),
1001 RootInfo {
1002 root_id: "a".into(),
1003 root_title: "Root".into(),
1004 status: ResolveStatus::Resolved,
1005 },
1006 );
1007 }
1008 Resolution {
1009 roots,
1010 gap_filled: Vec::new(),
1011 bridges: Vec::new(),
1012 }
1013 }
1014
1015 fn edge<'a>(store: &'a LineageStore, child: &str, parent: &str) -> &'a StoredEdge {
1016 store
1017 .edges
1018 .iter()
1019 .find(|e| e.child_id == child && e.parent_id == parent)
1020 .expect("edge should exist")
1021 }
1022
1023 #[test]
1024 fn new_store_is_empty_and_versioned() {
1025 let store = LineageStore::new();
1026 assert!(store.is_empty());
1027 assert_eq!(store.len(), 0);
1028 assert_eq!(store.schema_version, 1);
1029 }
1030
1031 #[test]
1032 fn update_populates_nodes_edges_and_cache() {
1033 let mut store = LineageStore::new();
1034 store.update(&chain_clips(), &chain_resolution(), "now");
1035
1036 assert_eq!(store.len(), 3);
1038 let cover = store.node("c").unwrap();
1039 assert_eq!(cover.title, "Cover");
1040 assert_eq!(cover.clip_type, "gen");
1041 assert_eq!(cover.task, "cover");
1042 assert_eq!(cover.created_at, "t2");
1043 assert_eq!(cover.status, NodeStatus::Observed);
1044 assert!(!cover.is_trashed);
1045 assert_eq!(cover.first_seen_at, "now");
1046 assert_eq!(cover.last_seen_at, "now");
1047
1048 assert_eq!(store.edges.len(), 2);
1050 let cb = edge(&store, "c", "b");
1051 assert_eq!(cb.edge_type, "cover");
1052 assert_eq!(cb.role, EdgeRole::Primary);
1053 assert_eq!(cb.ordinal, 0);
1054 assert_eq!(cb.source_field, "cover_clip_id");
1055 assert_eq!(cb.status, EdgeStatus::Active);
1056 let ba = edge(&store, "b", "a");
1057 assert_eq!(ba.edge_type, "remaster");
1058 assert!(!store.edges.iter().any(|e| e.child_id == "a"));
1059
1060 for id in ["a", "b", "c"] {
1062 let cached = store.get_root(id).unwrap();
1063 assert_eq!(cached.root_id, "a");
1064 assert_eq!(cached.status, ResolveStatus::Resolved);
1065 assert_eq!(cached.algorithm_version, 1);
1066 }
1067 }
1068
1069 #[test]
1070 fn update_persists_edges_for_gap_filled_ancestors() {
1071 let child = Clip {
1075 id: "child".into(),
1076 title: "Cover".into(),
1077 clip_type: "gen".into(),
1078 task: "cover".into(),
1079 cover_clip_id: "mid".into(),
1080 edited_clip_id: "mid".into(),
1081 ..Default::default()
1082 };
1083 let mid = Clip {
1084 id: "mid".into(),
1085 title: "Mid".into(),
1086 clip_type: "gen".into(),
1087 task: "cover".into(),
1088 cover_clip_id: "root".into(),
1089 edited_clip_id: "root".into(),
1090 ..Default::default()
1091 };
1092 let mut roots = HashMap::new();
1093 roots.insert(
1094 "child".to_owned(),
1095 RootInfo {
1096 root_id: "root".into(),
1097 root_title: "Original".into(),
1098 status: ResolveStatus::Resolved,
1099 },
1100 );
1101 let resolution = Resolution {
1102 roots,
1103 gap_filled: vec![mid],
1104 bridges: Vec::new(),
1105 };
1106 let mut store = LineageStore::new();
1107 store.update(std::slice::from_ref(&child), &resolution, "now");
1108
1109 let mid_edge = edge(&store, "mid", "root");
1111 assert_eq!(mid_edge.role, EdgeRole::Primary);
1112 assert_eq!(mid_edge.ordinal, 0);
1113 let archived = store.archived_parents();
1115 assert_eq!(archived.get("child").map(String::as_str), Some("mid"));
1116 assert_eq!(archived.get("mid").map(String::as_str), Some("root"));
1117 }
1118
1119 #[test]
1120 fn update_persists_bridges_as_edges() {
1121 let child = Clip {
1124 id: "child".into(),
1125 title: "Cover".into(),
1126 clip_type: "gen".into(),
1127 task: "cover".into(),
1128 cover_clip_id: "gone".into(),
1129 edited_clip_id: "gone".into(),
1130 ..Default::default()
1131 };
1132 let mut roots = HashMap::new();
1133 roots.insert(
1134 "child".to_owned(),
1135 RootInfo {
1136 root_id: "found".into(),
1137 root_title: String::new(),
1138 status: ResolveStatus::External,
1139 },
1140 );
1141 let resolution = Resolution {
1142 roots,
1143 gap_filled: Vec::new(),
1144 bridges: vec![("gone".to_owned(), "found".to_owned())],
1145 };
1146 let mut store = LineageStore::new();
1147 store.update(std::slice::from_ref(&child), &resolution, "now");
1148
1149 let bridged = edge(&store, "gone", "found");
1150 assert_eq!(bridged.source_field, "parent_endpoint");
1151 assert_eq!(bridged.role, EdgeRole::Primary);
1152 assert_eq!(bridged.ordinal, 0);
1153 assert_eq!(
1154 store.archived_parents().get("gone").map(String::as_str),
1155 Some("found")
1156 );
1157 }
1158
1159 #[test]
1160 fn archived_parents_maps_children_to_primary_parents_only() {
1161 let mut store = LineageStore::new();
1162 store.update(&chain_clips(), &chain_resolution(), "now");
1163 let archived = store.archived_parents();
1164 assert_eq!(archived.get("c").map(String::as_str), Some("b"));
1165 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
1166 assert!(
1167 !archived.contains_key("a"),
1168 "a root has no primary parent edge"
1169 );
1170 }
1171
1172 #[test]
1173 fn album_for_id_matches_context_for_and_handles_unknown() {
1174 let mut store = LineageStore::new();
1175 store.update(&chain_clips(), &chain_resolution(), "now");
1176
1177 assert_eq!(store.album_for_id("c"), "Root");
1180 let cover = &chain_clips()[0];
1181 assert_eq!(
1182 store.album_for_id("c"),
1183 store.context_for(cover).album(&cover.title)
1184 );
1185 assert_eq!(store.album_for_id("a"), "Root");
1187 assert_eq!(store.album_for_id("missing"), "");
1189 }
1190
1191 #[test]
1192 fn serde_roundtrip_preserves_a_relational_shape() {
1193 let mut store = LineageStore::new();
1194 store.update(&chain_clips(), &chain_resolution(), "now");
1195
1196 let json = serde_json::to_string(&store).unwrap();
1197 let back: LineageStore = serde_json::from_str(&json).unwrap();
1198 assert_eq!(store, back);
1199
1200 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1201 assert_eq!(value.get("schema_version").unwrap(), 1);
1202 assert!(value.get("nodes").unwrap().is_object());
1203 assert!(value.get("edges").unwrap().is_array());
1204 assert!(value.get("resolution_cache").unwrap().is_object());
1205 assert!(value.get("edge_index").is_none());
1206
1207 let node = value.get("nodes").unwrap().get("c").unwrap();
1210 assert!(node.get("edges").is_none());
1211 assert!(node.get("parent_id").is_none());
1212 let first_edge = value.get("edges").unwrap().get(0).unwrap();
1213 assert!(first_edge.get("child_id").is_some());
1214 assert!(first_edge.get("parent_id").is_some());
1215 }
1216
1217 #[test]
1218 fn album_overrides_are_runtime_only_and_never_persist() {
1219 let mut store = LineageStore::new();
1223 store.update(&chain_clips(), &chain_resolution(), "now");
1224 store.set_album_overrides(
1225 [("a".to_owned(), "Preferred".to_owned())]
1226 .into_iter()
1227 .collect(),
1228 );
1229
1230 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1231 assert!(value.get("album_overrides").is_none());
1232
1233 let json = serde_json::to_string(&store).unwrap();
1234 let back: LineageStore = serde_json::from_str(&json).unwrap();
1235 assert!(back.album_overrides.is_empty());
1236 assert_eq!(back.album_for_id("c"), "Root");
1237 }
1238
1239 #[test]
1240 fn update_is_idempotent_bar_last_seen() {
1241 let clips = chain_clips();
1242 let resolution = chain_resolution();
1243 let mut store = LineageStore::new();
1244 store.update(&clips, &resolution, "first");
1245 let node_ids: Vec<String> = store.iter().map(|(id, _)| id.clone()).collect();
1246 let edge_count = store.edges.len();
1247
1248 store.update(&clips, &resolution, "second");
1249
1250 assert_eq!(
1252 store.iter().map(|(id, _)| id.clone()).collect::<Vec<_>>(),
1253 node_ids
1254 );
1255 assert_eq!(store.edges.len(), edge_count, "edges must not duplicate");
1256 assert_eq!(store.resolution_cache.len(), 3);
1257
1258 let cover = store.node("c").unwrap();
1260 assert_eq!(cover.first_seen_at, "first");
1261 assert_eq!(cover.last_seen_at, "second");
1262 let cb = edge(&store, "c", "b");
1263 assert_eq!(cb.first_seen_at, "first");
1264 assert_eq!(cb.last_seen_at, "second");
1265 assert_eq!(store.get_root("c").unwrap().root_id, "a");
1267 }
1268
1269 #[test]
1270 fn cache_is_monotonic_and_never_downgrades_a_resolved_root() {
1271 let mut store = LineageStore::new();
1272 store.update(&chain_clips(), &chain_resolution(), "first");
1273 assert_eq!(store.get_root("c").unwrap().status, ResolveStatus::Resolved);
1274
1275 let child = Clip {
1278 id: "c".into(),
1279 title: "Cover".into(),
1280 clip_type: "gen".into(),
1281 task: "cover".into(),
1282 cover_clip_id: "b".into(),
1283 edited_clip_id: "b".into(),
1284 ..Default::default()
1285 };
1286 let mut roots = HashMap::new();
1287 roots.insert(
1288 "c".to_owned(),
1289 RootInfo {
1290 root_id: "elsewhere".into(),
1291 root_title: String::new(),
1292 status: ResolveStatus::External,
1293 },
1294 );
1295 roots.insert(
1296 "d".to_owned(),
1297 RootInfo {
1298 root_id: "boundary".into(),
1299 root_title: String::new(),
1300 status: ResolveStatus::External,
1301 },
1302 );
1303 let resolution = Resolution {
1304 roots,
1305 gap_filled: Vec::new(),
1306 bridges: Vec::new(),
1307 };
1308 store.update(&[child], &resolution, "second");
1309
1310 let cached = store.get_root("c").unwrap();
1312 assert_eq!(cached.root_id, "a");
1313 assert_eq!(cached.status, ResolveStatus::Resolved);
1314 assert_eq!(cached.computed_at, "first");
1315 let d = store.get_root("d").unwrap();
1317 assert_eq!(d.root_id, "boundary");
1318 assert_eq!(d.status, ResolveStatus::External);
1319 }
1320
1321 #[test]
1322 fn gap_filled_trashed_ancestor_is_a_durable_node() {
1323 let child = Clip {
1327 id: "c".into(),
1328 title: "Cover".into(),
1329 clip_type: "gen".into(),
1330 task: "cover".into(),
1331 cover_clip_id: "t".into(),
1332 edited_clip_id: "t".into(),
1333 ..Default::default()
1334 };
1335 let trashed = Clip {
1336 id: "t".into(),
1337 title: "Trashed Original".into(),
1338 clip_type: "gen".into(),
1339 is_trashed: true,
1340 ..Default::default()
1341 };
1342 let mut roots = HashMap::new();
1343 roots.insert(
1344 "c".to_owned(),
1345 RootInfo {
1346 root_id: "t".into(),
1347 root_title: "Trashed Original".into(),
1348 status: ResolveStatus::Resolved,
1349 },
1350 );
1351 let resolution = Resolution {
1352 roots,
1353 gap_filled: vec![trashed],
1354 bridges: Vec::new(),
1355 };
1356 store_update_and_assert_trashed(child, resolution);
1357 }
1358
1359 fn store_update_and_assert_trashed(child: Clip, resolution: Resolution) {
1360 let mut store = LineageStore::new();
1361 store.update(&[child], &resolution, "now");
1362
1363 let node = store
1364 .node("t")
1365 .expect("trashed ancestor should be archived");
1366 assert!(node.is_trashed);
1367 assert_eq!(node.title, "Trashed Original");
1368 assert_eq!(store.get_root("c").unwrap().root_id, "t");
1370 }
1371
1372 #[test]
1373 fn partial_json_loads_with_defaults() {
1374 let json = r#"{"nodes":{"x":{"title":"Kept"}},"edges":[{"child_id":"x","parent_id":"y"}]}"#;
1377 let store: LineageStore = serde_json::from_str(json).unwrap();
1378 assert_eq!(store.schema_version, 1);
1379 let node = store.node("x").unwrap();
1380 assert_eq!(node.title, "Kept");
1381 assert_eq!(node.status, NodeStatus::Observed);
1382 assert_eq!(store.edges[0].status, EdgeStatus::Active);
1383 assert!(store.resolution_cache.is_empty());
1384 assert!(store.albums.is_empty());
1387 assert!(store.album_art("x").is_none());
1388 assert!(store.playlists.is_empty());
1392 assert!(store.playlist("x").is_none());
1393 }
1394
1395 #[test]
1396 fn album_art_roundtrips_and_reads_by_kind() {
1397 let mut store = LineageStore::new();
1398 store.albums.insert(
1399 "root-1".to_owned(),
1400 AlbumArt {
1401 folder_jpg: Some(ArtifactState {
1402 path: "alice/Album/folder.jpg".to_owned(),
1403 hash: "jpg-h".to_owned(),
1404 }),
1405 folder_webp: Some(ArtifactState {
1406 path: "alice/Album/cover.webp".to_owned(),
1407 hash: "webp-h".to_owned(),
1408 }),
1409 folder_mp4: Some(ArtifactState {
1410 path: "alice/Album/cover.mp4".to_owned(),
1411 hash: "mp4-h".to_owned(),
1412 }),
1413 },
1414 );
1415
1416 let json = serde_json::to_string(&store).unwrap();
1417 let back: LineageStore = serde_json::from_str(&json).unwrap();
1418 assert_eq!(store, back);
1419
1420 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1422 let album = value.get("albums").unwrap().get("root-1").unwrap();
1423 assert_eq!(
1424 album.get("folder_jpg").unwrap().get("hash").unwrap(),
1425 "jpg-h"
1426 );
1427
1428 let art = back.album_art("root-1").unwrap();
1429 assert_eq!(
1430 art.artifact(ArtifactKind::FolderJpg).unwrap().path,
1431 "alice/Album/folder.jpg"
1432 );
1433 assert_eq!(
1434 art.artifact(ArtifactKind::FolderWebp).unwrap().hash,
1435 "webp-h"
1436 );
1437 assert_eq!(art.artifact(ArtifactKind::FolderMp4).unwrap().hash, "mp4-h");
1438 assert!(art.artifact(ArtifactKind::CoverJpg).is_none());
1440 }
1441
1442 #[test]
1443 fn empty_album_art_omits_slots_when_serialised() {
1444 let empty = AlbumArt::default();
1447 assert!(empty.is_empty());
1448 let value = serde_json::to_value(&empty).unwrap();
1449 assert!(value.get("folder_jpg").is_none());
1450 assert!(value.get("folder_webp").is_none());
1451 let back: AlbumArt = serde_json::from_str("{}").unwrap();
1452 assert_eq!(back, empty);
1453 }
1454
1455 #[test]
1456 fn set_album_artifact_upserts_then_prunes_when_emptied() {
1457 let mut store = LineageStore::new();
1458 let jpg = ArtifactState {
1459 path: "a/folder.jpg".to_owned(),
1460 hash: "h1".to_owned(),
1461 };
1462 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, Some(jpg.clone()));
1463 assert_eq!(store.album_art("root-1").unwrap().folder_jpg, Some(jpg));
1464
1465 store.set_album_artifact("root-1", ArtifactKind::FolderJpg, None);
1467 assert!(store.album_art("root-1").is_none());
1468 assert!(store.albums.is_empty());
1469 }
1470
1471 #[test]
1472 fn album_row_survives_until_the_last_slot_including_folder_mp4_is_cleared() {
1473 let mut store = LineageStore::new();
1478 let state = |p: &str| ArtifactState {
1479 path: p.to_owned(),
1480 hash: "h".to_owned(),
1481 };
1482 store.set_album_artifact(
1483 "root-1",
1484 ArtifactKind::FolderWebp,
1485 Some(state("a/cover.webp")),
1486 );
1487 store.set_album_artifact(
1488 "root-1",
1489 ArtifactKind::FolderMp4,
1490 Some(state("a/cover.mp4")),
1491 );
1492
1493 store.set_album_artifact("root-1", ArtifactKind::FolderWebp, None);
1496 let art = store
1497 .album_art("root-1")
1498 .expect("row kept while folder_mp4 remains");
1499 assert!(!art.is_empty());
1500 assert!(art.folder_mp4.is_some());
1501
1502 store.set_album_artifact("root-1", ArtifactKind::FolderMp4, None);
1504 assert!(store.album_art("root-1").is_none());
1505 assert!(store.albums.is_empty());
1506 }
1507
1508 #[test]
1509 fn playlist_state_roundtrips_by_id() {
1510 let mut store = LineageStore::new();
1511 store.playlists.insert(
1512 "pl1".to_owned(),
1513 PlaylistState {
1514 name: "Road Trip".to_owned(),
1515 path: "Road Trip.m3u8".to_owned(),
1516 hash: "abc123".to_owned(),
1517 },
1518 );
1519
1520 let json = serde_json::to_string(&store).unwrap();
1521 let back: LineageStore = serde_json::from_str(&json).unwrap();
1522 assert_eq!(store, back);
1523
1524 let value: serde_json::Value = serde_json::to_value(&store).unwrap();
1526 let pl = value.get("playlists").unwrap().get("pl1").unwrap();
1527 assert_eq!(pl.get("path").unwrap(), "Road Trip.m3u8");
1528 assert_eq!(pl.get("hash").unwrap(), "abc123");
1529
1530 let stored = back.playlist("pl1").unwrap();
1531 assert_eq!(stored.name, "Road Trip");
1532 assert_eq!(stored.hash, "abc123");
1533 }
1534
1535 #[test]
1536 fn set_playlist_upserts_then_clears() {
1537 let mut store = LineageStore::new();
1538 let state = PlaylistState {
1539 name: "Mix".to_owned(),
1540 path: "Mix.m3u8".to_owned(),
1541 hash: "h1".to_owned(),
1542 };
1543 store.set_playlist("pl1", Some(state.clone()));
1544 assert_eq!(store.playlist("pl1"), Some(&state));
1545
1546 let renamed = PlaylistState {
1548 name: "Mix v2".to_owned(),
1549 path: "Mix v2.m3u8".to_owned(),
1550 hash: "h2".to_owned(),
1551 };
1552 store.set_playlist("pl1", Some(renamed.clone()));
1553 assert_eq!(store.playlist("pl1"), Some(&renamed));
1554
1555 store.set_playlist("pl1", None);
1557 assert!(store.playlist("pl1").is_none());
1558 assert!(store.playlists.is_empty());
1559 }
1560
1561 #[test]
1562 fn context_for_roots_a_remix_at_its_stored_ancestor() {
1563 let mut store = LineageStore::new();
1564 store.update(&chain_clips(), &chain_resolution(), "now");
1565
1566 let child = &chain_clips()[0]; let ctx = store.context_for(child);
1568 assert_eq!(ctx.root_id, "a");
1569 assert_eq!(ctx.root_title, "Root");
1570 assert_eq!(ctx.parent_id, "b");
1571 assert_eq!(ctx.edge_type, Some(EdgeType::Cover));
1572 assert_eq!(ctx.status, ResolveStatus::Resolved);
1573 assert_eq!(ctx.album("Cover"), "Root");
1575 }
1576
1577 #[test]
1578 fn context_for_a_root_uses_its_own_title_and_has_no_parent() {
1579 let mut store = LineageStore::new();
1580 store.update(&chain_clips(), &chain_resolution(), "now");
1581
1582 let root = &chain_clips()[2]; let ctx = store.context_for(root);
1584 assert_eq!(ctx.root_id, "a");
1585 assert_eq!(ctx.root_title, "Root");
1586 assert_eq!(ctx.parent_id, "");
1587 assert_eq!(ctx.edge_type, None);
1588 assert_eq!(ctx.album("Root"), "Root");
1589 }
1590
1591 #[test]
1592 fn context_for_tags_the_root_year_across_a_calendar_boundary() {
1593 let clips = vec![
1596 Clip {
1597 id: "child".into(),
1598 title: "Revision".into(),
1599 clip_type: "gen".into(),
1600 task: "cover".into(),
1601 created_at: "2024-01-02T08:00:00Z".into(),
1602 cover_clip_id: "root".into(),
1603 edited_clip_id: "root".into(),
1604 ..Default::default()
1605 },
1606 Clip {
1607 id: "root".into(),
1608 title: "Origin".into(),
1609 clip_type: "gen".into(),
1610 created_at: "2023-12-30T23:00:00Z".into(),
1611 ..Default::default()
1612 },
1613 ];
1614 let mut roots = HashMap::new();
1615 for id in ["child", "root"] {
1616 roots.insert(
1617 id.to_owned(),
1618 RootInfo {
1619 root_id: "root".into(),
1620 root_title: "Origin".into(),
1621 status: ResolveStatus::Resolved,
1622 },
1623 );
1624 }
1625 let resolution = Resolution {
1626 roots,
1627 gap_filled: Vec::new(),
1628 bridges: Vec::new(),
1629 };
1630 let mut store = LineageStore::new();
1631 store.update(&clips, &resolution, "now");
1632
1633 let child_ctx = store.context_for(&clips[0]);
1634 assert_eq!(child_ctx.root_id, "root");
1635 assert_eq!(child_ctx.root_date, "2023-12-30T23:00:00Z");
1636 assert_eq!(child_ctx.year(&clips[0].created_at), "2023");
1638
1639 let root_ctx = store.context_for(&clips[1]);
1641 assert_eq!(root_ctx.year(&clips[1].created_at), "2023");
1642 }
1643
1644 #[test]
1645 fn context_for_an_unknown_clip_is_self_rooted() {
1646 let store = LineageStore::new();
1647 let orphan = Clip {
1648 id: "z".into(),
1649 title: "Lonely".into(),
1650 ..Default::default()
1651 };
1652 let ctx = store.context_for(&orphan);
1653 assert_eq!(ctx.root_id, "z");
1654 assert_eq!(ctx.root_title, "Lonely");
1655 assert_eq!(ctx.parent_id, "");
1656 assert_eq!(ctx.status, ResolveStatus::Resolved);
1657 }
1658
1659 #[test]
1660 fn context_for_retains_a_purged_ancestor_album() {
1661 let child = Clip {
1666 id: "c".into(),
1667 title: "Cover".into(),
1668 clip_type: "gen".into(),
1669 task: "cover".into(),
1670 cover_clip_id: "t".into(),
1671 edited_clip_id: "t".into(),
1672 ..Default::default()
1673 };
1674 let trashed = Clip {
1675 id: "t".into(),
1676 title: "Trashed Original".into(),
1677 clip_type: "gen".into(),
1678 is_trashed: true,
1679 ..Default::default()
1680 };
1681 let mut roots = HashMap::new();
1682 roots.insert(
1683 "c".to_owned(),
1684 RootInfo {
1685 root_id: "t".into(),
1686 root_title: "Trashed Original".into(),
1687 status: ResolveStatus::Resolved,
1688 },
1689 );
1690 let resolution = Resolution {
1691 roots,
1692 gap_filled: vec![trashed],
1693 bridges: Vec::new(),
1694 };
1695 let mut store = LineageStore::new();
1696 store.update(std::slice::from_ref(&child), &resolution, "now");
1697
1698 let ctx = store.context_for(&child);
1699 assert_eq!(ctx.root_id, "t");
1700 assert_eq!(ctx.root_title, "Trashed Original");
1701 assert_eq!(ctx.album("Cover"), "Trashed Original");
1702 }
1703
1704 #[test]
1705 fn colliding_root_titles_flags_only_shared_distinct_roots() {
1706 let clips = vec![
1709 Clip {
1710 id: "r1".into(),
1711 title: "Break Through".into(),
1712 clip_type: "gen".into(),
1713 ..Default::default()
1714 },
1715 Clip {
1716 id: "r2".into(),
1717 title: "Break Through".into(),
1718 clip_type: "gen".into(),
1719 ..Default::default()
1720 },
1721 Clip {
1722 id: "r3".into(),
1723 title: "Solo".into(),
1724 clip_type: "gen".into(),
1725 ..Default::default()
1726 },
1727 Clip {
1728 id: "c1".into(),
1729 title: "Break Through".into(),
1730 clip_type: "gen".into(),
1731 task: "cover".into(),
1732 cover_clip_id: "r1".into(),
1733 edited_clip_id: "r1".into(),
1734 ..Default::default()
1735 },
1736 ];
1737 let mut roots = HashMap::new();
1738 for (id, root) in [("r1", "r1"), ("r2", "r2"), ("r3", "r3"), ("c1", "r1")] {
1739 let title = if root == "r3" {
1740 "Solo"
1741 } else {
1742 "Break Through"
1743 };
1744 roots.insert(
1745 id.to_owned(),
1746 RootInfo {
1747 root_id: root.into(),
1748 root_title: title.into(),
1749 status: ResolveStatus::Resolved,
1750 },
1751 );
1752 }
1753 let resolution = Resolution {
1754 roots,
1755 gap_filled: Vec::new(),
1756 bridges: Vec::new(),
1757 };
1758 let mut store = LineageStore::new();
1759 store.update(&clips, &resolution, "now");
1760
1761 let colliding = store.colliding_root_titles();
1762 assert!(colliding.contains("Break Through"));
1763 assert!(!colliding.contains("Solo"));
1764 assert_eq!(colliding.len(), 1);
1765 }
1766
1767 fn two_root_store(t1: &str, t2: &str) -> LineageStore {
1770 let clips = vec![
1771 Clip {
1772 id: "r1".into(),
1773 title: t1.into(),
1774 clip_type: "gen".into(),
1775 ..Default::default()
1776 },
1777 Clip {
1778 id: "r2".into(),
1779 title: t2.into(),
1780 clip_type: "gen".into(),
1781 ..Default::default()
1782 },
1783 ];
1784 let mut roots = HashMap::new();
1785 roots.insert(
1786 "r1".to_owned(),
1787 RootInfo {
1788 root_id: "r1".into(),
1789 root_title: t1.into(),
1790 status: ResolveStatus::Resolved,
1791 },
1792 );
1793 roots.insert(
1794 "r2".to_owned(),
1795 RootInfo {
1796 root_id: "r2".into(),
1797 root_title: t2.into(),
1798 status: ResolveStatus::Resolved,
1799 },
1800 );
1801 let mut store = LineageStore::new();
1802 store.update(
1803 &clips,
1804 &Resolution {
1805 roots,
1806 gap_filled: Vec::new(),
1807 bridges: Vec::new(),
1808 },
1809 "now",
1810 );
1811 store
1812 }
1813
1814 #[test]
1815 fn album_override_flows_into_context_tag_hash_and_index() {
1816 let clips = chain_clips();
1820 let mut store = LineageStore::new();
1821 store.update(&clips, &chain_resolution(), "now");
1822
1823 let cover = &clips[0]; let before_hash = crate::hash::meta_hash(cover, &store.context_for(cover));
1825
1826 store.set_album_overrides(
1827 [("a".to_owned(), "Preferred Name".to_owned())]
1828 .into_iter()
1829 .collect(),
1830 );
1831
1832 for id in ["a", "b", "c"] {
1834 let clip = clips.iter().find(|c| c.id == id).unwrap();
1835 let ctx = store.context_for(clip);
1836 assert_eq!(ctx.album(&clip.title), "Preferred Name");
1837 assert_eq!(store.album_for_id(id), "Preferred Name");
1838 }
1839
1840 let ctx = store.context_for(cover);
1842 let meta = crate::tag::TrackMetadata::from_clip(cover, &ctx);
1843 assert_eq!(meta.album, "Preferred Name");
1844
1845 let after_hash = crate::hash::meta_hash(cover, &ctx);
1847 assert_ne!(before_hash, after_hash);
1848 }
1849
1850 #[test]
1851 fn empty_album_override_is_ignored() {
1852 let clips = chain_clips();
1854 let mut store = LineageStore::new();
1855 store.update(&clips, &chain_resolution(), "now");
1856 store.set_album_overrides([("a".to_owned(), " ".to_owned())].into_iter().collect());
1857 assert_eq!(store.album_for_id("c"), "Root");
1858 }
1859
1860 #[test]
1861 fn album_override_creates_a_collision_that_disambiguates() {
1862 let mut store = two_root_store("Alpha", "Beta");
1864 assert!(store.colliding_root_titles().is_empty());
1865
1866 store.set_album_overrides(
1867 [("r2".to_owned(), "Alpha".to_owned())]
1868 .into_iter()
1869 .collect(),
1870 );
1871 let colliding = store.colliding_root_titles();
1872 assert!(colliding.contains("Alpha"));
1873 assert_eq!(colliding.len(), 1);
1874 }
1875
1876 #[test]
1877 fn album_override_resolves_a_natural_collision() {
1878 let mut store = two_root_store("Break Through", "Break Through");
1880 assert!(store.colliding_root_titles().contains("Break Through"));
1881
1882 store.set_album_overrides(
1883 [("r2".to_owned(), "Second Wind".to_owned())]
1884 .into_iter()
1885 .collect(),
1886 );
1887 assert!(store.colliding_root_titles().is_empty());
1888 }
1889
1890 fn insert_cache_only_root(store: &mut LineageStore, root_id: &str) {
1895 store.resolution_cache.insert(
1896 root_id.to_owned(),
1897 CacheEntry {
1898 root_id: root_id.to_owned(),
1899 status: ResolveStatus::External,
1900 algorithm_version: 1,
1901 computed_at: "now".to_owned(),
1902 },
1903 );
1904 store.refresh_eligible_roots();
1907 }
1908
1909 #[test]
1910 fn override_on_node_less_root_collides_with_a_real_root() {
1911 let mut store = LineageStore::new();
1915 store.update(
1916 std::slice::from_ref(&Clip {
1917 id: "realroot".into(),
1918 title: "Shared".into(),
1919 clip_type: "gen".into(),
1920 ..Default::default()
1921 }),
1922 &Resolution {
1923 roots: [(
1924 "realroot".to_owned(),
1925 RootInfo {
1926 root_id: "realroot".into(),
1927 root_title: "Shared".into(),
1928 status: ResolveStatus::Resolved,
1929 },
1930 )]
1931 .into_iter()
1932 .collect(),
1933 gap_filled: Vec::new(),
1934 bridges: Vec::new(),
1935 },
1936 "now",
1937 );
1938 insert_cache_only_root(&mut store, "extroot");
1939 store.set_album_overrides(
1940 [("extroot".to_owned(), "Shared".to_owned())]
1941 .into_iter()
1942 .collect(),
1943 );
1944
1945 let colliding = store.colliding_root_titles();
1946 assert!(
1947 colliding.contains("Shared"),
1948 "a node-less overridden root must still be seen by collision detection"
1949 );
1950 }
1951
1952 #[test]
1953 fn two_node_less_roots_overridden_to_same_name_collide() {
1954 let mut store = LineageStore::new();
1955 insert_cache_only_root(&mut store, "extone");
1956 insert_cache_only_root(&mut store, "exttwo");
1957 store.set_album_overrides(
1958 [
1959 ("extone".to_owned(), "Shared".to_owned()),
1960 ("exttwo".to_owned(), "Shared".to_owned()),
1961 ]
1962 .into_iter()
1963 .collect(),
1964 );
1965 assert!(store.colliding_root_titles().contains("Shared"));
1966 }
1967
1968 #[test]
1969 fn colliding_node_less_overrides_keep_album_art_paths_distinct() {
1970 let mut store = LineageStore::new();
1975 insert_cache_only_root(&mut store, "aaaaaaaa-root-one");
1976 insert_cache_only_root(&mut store, "bbbbbbbb-root-two");
1977 store.set_album_overrides(
1978 [
1979 ("aaaaaaaa-root-one".to_owned(), "Shared".to_owned()),
1980 ("bbbbbbbb-root-two".to_owned(), "Shared".to_owned()),
1981 ]
1982 .into_iter()
1983 .collect(),
1984 );
1985 let colliding = store.colliding_root_titles();
1986
1987 let clip_of = |id: &str| Clip {
1988 id: id.to_owned(),
1989 title: "Track".to_owned(),
1990 display_name: "alice".to_owned(),
1991 image_large_url: "https://art.example/large.jpg".to_owned(),
1992 ..Default::default()
1993 };
1994 let ctx_of = |root_id: &str| LineageContext {
1995 root_id: root_id.to_owned(),
1996 root_title: "Shared".to_owned(),
1997 root_date: String::new(),
1998 parent_id: String::new(),
1999 edge_type: None,
2000 status: ResolveStatus::Resolved,
2001 };
2002 let clip_a = clip_of("clipaaaa-1111");
2003 let clip_b = clip_of("clipbbbb-2222");
2004 let ctx_a = ctx_of("aaaaaaaa-root-one");
2005 let ctx_b = ctx_of("bbbbbbbb-root-two");
2006 let requests = [
2007 crate::naming::NamingRequest {
2008 clip: &clip_a,
2009 lineage: &ctx_a,
2010 },
2011 crate::naming::NamingRequest {
2012 clip: &clip_b,
2013 lineage: &ctx_b,
2014 },
2015 ];
2016 let names = crate::naming::render_clip_names(
2017 &requests,
2018 &crate::naming::NamingConfig::default(),
2019 &colliding,
2020 );
2021
2022 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2023 crate::reconcile::Desired {
2024 clip: clip.clone(),
2025 lineage: ctx.clone(),
2026 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2027 format: crate::AudioFormat::Flac,
2028 meta_hash: String::new(),
2029 art_hash: String::new(),
2030 modes: vec![crate::reconcile::SourceMode::Mirror],
2031 trashed: false,
2032 private: false,
2033 artifacts: Vec::new(),
2034 stems: None,
2035 }
2036 };
2037 let desired = vec![
2038 desired_of(&clip_a, &ctx_a, &names[0]),
2039 desired_of(&clip_b, &ctx_b, &names[1]),
2040 ];
2041
2042 let albums = crate::reconcile::album_desired(&desired, false, false);
2043 assert_eq!(albums.len(), 2, "each distinct root is its own album");
2044 let jpg_paths: Vec<String> = albums
2045 .iter()
2046 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2047 .collect();
2048 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2049 assert_ne!(
2050 jpg_paths[0], jpg_paths[1],
2051 "colliding roots must not share one folder.jpg path"
2052 );
2053 }
2054
2055 #[test]
2056 fn override_on_uncached_selected_root_is_ignored_and_keeps_albums_distinct() {
2057 let mut store = LineageStore::new();
2064 store.update(
2065 std::slice::from_ref(&Clip {
2066 id: "realroot".into(),
2067 title: "Shared".into(),
2068 clip_type: "gen".into(),
2069 ..Default::default()
2070 }),
2071 &Resolution {
2072 roots: [(
2073 "realroot".to_owned(),
2074 RootInfo {
2075 root_id: "realroot".into(),
2076 root_title: "Shared".into(),
2077 status: ResolveStatus::Resolved,
2078 },
2079 )]
2080 .into_iter()
2081 .collect(),
2082 gap_filled: Vec::new(),
2083 bridges: Vec::new(),
2084 },
2085 "now",
2086 );
2087 let new_clip = Clip {
2090 id: "newnewnew-9999".into(),
2091 title: "Solo Track".into(),
2092 display_name: "alice".into(),
2093 image_large_url: "https://art.example/large.jpg".into(),
2094 ..Default::default()
2095 };
2096 store.set_album_overrides(
2097 [("newnewnew-9999".to_owned(), "Shared".to_owned())]
2098 .into_iter()
2099 .collect(),
2100 );
2101
2102 let new_ctx = store.context_for(&new_clip);
2104 assert_eq!(new_ctx.root_id, "newnewnew-9999");
2105 assert_eq!(new_ctx.album(&new_clip.title), "Solo Track");
2106
2107 assert!(store.colliding_root_titles().is_empty());
2109
2110 let real_clip = Clip {
2112 id: "realroot".into(),
2113 title: "Shared".into(),
2114 display_name: "alice".into(),
2115 image_large_url: "https://art.example/large.jpg".into(),
2116 ..Default::default()
2117 };
2118 let real_ctx = store.context_for(&real_clip);
2119 let colliding = store.colliding_root_titles();
2120 let requests = [
2121 crate::naming::NamingRequest {
2122 clip: &real_clip,
2123 lineage: &real_ctx,
2124 },
2125 crate::naming::NamingRequest {
2126 clip: &new_clip,
2127 lineage: &new_ctx,
2128 },
2129 ];
2130 let names = crate::naming::render_clip_names(
2131 &requests,
2132 &crate::naming::NamingConfig::default(),
2133 &colliding,
2134 );
2135 let desired_of = |clip: &Clip, ctx: &LineageContext, name: &crate::naming::RenderedName| {
2136 crate::reconcile::Desired {
2137 clip: clip.clone(),
2138 lineage: ctx.clone(),
2139 path: format!("{}.flac", name.relative_path.to_string_lossy()),
2140 format: crate::AudioFormat::Flac,
2141 meta_hash: String::new(),
2142 art_hash: String::new(),
2143 modes: vec![crate::reconcile::SourceMode::Mirror],
2144 trashed: false,
2145 private: false,
2146 artifacts: Vec::new(),
2147 stems: None,
2148 }
2149 };
2150 let desired = vec![
2151 desired_of(&real_clip, &real_ctx, &names[0]),
2152 desired_of(&new_clip, &new_ctx, &names[1]),
2153 ];
2154 let albums = crate::reconcile::album_desired(&desired, false, false);
2155 let jpg_paths: Vec<String> = albums
2156 .iter()
2157 .filter_map(|a| a.folder_jpg.as_ref().map(|art| art.path.clone()))
2158 .collect();
2159 assert_eq!(jpg_paths.len(), 2, "both albums have a folder.jpg");
2160 assert_ne!(
2161 jpg_paths[0], jpg_paths[1],
2162 "an uncached override must not collapse two albums onto one path"
2163 );
2164 }
2165
2166 #[test]
2167 fn override_on_gap_filled_root_applies_to_children_and_collides() {
2168 let child = Clip {
2175 id: "childclip".into(),
2176 title: "Cover".into(),
2177 clip_type: "gen".into(),
2178 task: "cover".into(),
2179 cover_clip_id: "gaproot".into(),
2180 edited_clip_id: "gaproot".into(),
2181 ..Default::default()
2182 };
2183 let other_root = Clip {
2184 id: "otherroot".into(),
2185 title: "Preferred".into(),
2186 clip_type: "gen".into(),
2187 ..Default::default()
2188 };
2189 let gap_ancestor = Clip {
2190 id: "gaproot".into(),
2191 title: "Working Title".into(),
2192 clip_type: "gen".into(),
2193 ..Default::default()
2194 };
2195 let mut roots = HashMap::new();
2196 roots.insert(
2197 "childclip".to_owned(),
2198 RootInfo {
2199 root_id: "gaproot".into(),
2200 root_title: "Working Title".into(),
2201 status: ResolveStatus::Resolved,
2202 },
2203 );
2204 roots.insert(
2205 "otherroot".to_owned(),
2206 RootInfo {
2207 root_id: "otherroot".into(),
2208 root_title: "Preferred".into(),
2209 status: ResolveStatus::Resolved,
2210 },
2211 );
2212 let mut store = LineageStore::new();
2213 store.update(
2214 &[child.clone(), other_root],
2215 &Resolution {
2216 roots,
2217 gap_filled: vec![gap_ancestor],
2218 bridges: Vec::new(),
2219 },
2220 "now",
2221 );
2222 assert!(store.node("gaproot").is_some());
2224 assert!(!store.resolution_cache.contains_key("gaproot"));
2225
2226 store.set_album_overrides(
2227 [("gaproot".to_owned(), "Preferred".to_owned())]
2228 .into_iter()
2229 .collect(),
2230 );
2231
2232 assert_eq!(store.context_for(&child).album(&child.title), "Preferred");
2235 assert_eq!(store.album_for_id("childclip"), "Preferred");
2236
2237 assert!(store.colliding_root_titles().contains("Preferred"));
2240 }
2241
2242 #[test]
2243 fn eligible_root_set_is_exactly_the_cache_value_domain() {
2244 let child = Clip {
2250 id: "childclip".into(),
2251 title: "Cover".into(),
2252 clip_type: "gen".into(),
2253 task: "cover".into(),
2254 cover_clip_id: "gaproot".into(),
2255 edited_clip_id: "gaproot".into(),
2256 ..Default::default()
2257 };
2258 let mut roots = HashMap::new();
2259 roots.insert(
2260 "childclip".to_owned(),
2261 RootInfo {
2262 root_id: "gaproot".into(),
2263 root_title: "Working Title".into(),
2264 status: ResolveStatus::Resolved,
2265 },
2266 );
2267 let mut store = LineageStore::new();
2268 store.update(
2269 std::slice::from_ref(&child),
2270 &Resolution {
2271 roots,
2272 gap_filled: vec![Clip {
2273 id: "gaproot".into(),
2274 title: "Working Title".into(),
2275 clip_type: "gen".into(),
2276 ..Default::default()
2277 }],
2278 bridges: Vec::new(),
2279 },
2280 "now",
2281 );
2282
2283 let expected: std::collections::HashSet<String> = store
2284 .resolution_cache
2285 .values()
2286 .map(|entry| entry.root_id.clone())
2287 .filter(|root_id| !root_id.is_empty())
2288 .collect();
2289 assert_eq!(*store.eligible_root_ids_for_test(), expected);
2290 assert!(store.eligible_root_ids_for_test().contains("gaproot"));
2292 assert!(!store.resolution_cache.contains_key("gaproot"));
2293 }
2294
2295 fn owner(id: &str, name: &str) -> Owner {
2296 Owner {
2297 user_id: id.to_owned(),
2298 display_name: name.to_owned(),
2299 }
2300 }
2301
2302 #[test]
2303 fn refresh_display_name_only_when_changed_and_never_when_unpinned() {
2304 let mut store = LineageStore::new();
2305 assert!(!store.refresh_display_name("Alice"));
2307 assert!(store.owner().is_none());
2308
2309 store.pin_owner(owner("user_a", "Alice"));
2310 assert!(!store.refresh_display_name("Alice"));
2312 assert!(store.refresh_display_name("Alice Cooper"));
2314 assert_eq!(store.owner().unwrap().display_name, "Alice Cooper");
2315 assert_eq!(store.owner().unwrap().user_id, "user_a");
2317 }
2318
2319 #[test]
2320 fn owner_gate_covers_the_full_matrix() {
2321 let alice = owner("user_a", "Alice");
2322
2323 assert_eq!(owner_gate(None, None, "user_a", false), OwnerGate::FirstUse);
2325 assert_eq!(owner_gate(None, None, "user_a", true), OwnerGate::FirstUse);
2326
2327 assert_eq!(
2329 owner_gate(Some(&alice), None, "user_a", false),
2330 OwnerGate::Proceed
2331 );
2332
2333 assert_eq!(
2335 owner_gate(Some(&alice), None, "user_b", false),
2336 OwnerGate::AbortMismatch
2337 );
2338 assert_eq!(
2339 owner_gate(Some(&alice), None, "user_b", true),
2340 OwnerGate::Repin
2341 );
2342
2343 assert_eq!(
2346 owner_gate(Some(&alice), Some("user_c"), "user_a", true),
2347 OwnerGate::AbortConfigMismatch
2348 );
2349 assert_eq!(
2350 owner_gate(None, Some("user_c"), "user_a", true),
2351 OwnerGate::AbortConfigMismatch
2352 );
2353 assert_eq!(
2355 owner_gate(Some(&alice), Some("user_a"), "user_a", false),
2356 OwnerGate::Proceed
2357 );
2358
2359 assert!(OwnerGate::Repin.is_additive());
2361 for gate in [
2362 OwnerGate::AbortConfigMismatch,
2363 OwnerGate::AbortMismatch,
2364 OwnerGate::Proceed,
2365 OwnerGate::FirstUse,
2366 ] {
2367 assert!(!gate.is_additive());
2368 }
2369 }
2370
2371 #[test]
2372 fn update_after_roundtrip_rebuilds_edge_index_without_duplicates() {
2373 let clips = chain_clips();
2374 let resolution = chain_resolution();
2375
2376 let mut store = LineageStore::new();
2377 store.update(&clips, &resolution, "first");
2378
2379 let json = serde_json::to_string(&store).unwrap();
2380 let mut store: LineageStore = serde_json::from_str(&json).unwrap();
2381
2382 store.update(&clips, &resolution, "second");
2383
2384 assert_eq!(store.edges.len(), 2);
2385 let cb = edge(&store, "c", "b");
2386 assert_eq!(cb.first_seen_at, "first");
2387 assert_eq!(cb.last_seen_at, "second");
2388 let ba = edge(&store, "b", "a");
2389 assert_eq!(ba.first_seen_at, "first");
2390 assert_eq!(ba.last_seen_at, "second");
2391 }
2392
2393 #[test]
2394 fn adopt_decision_covers_every_branch() {
2395 let owned: BTreeSet<&str> = ["c1", "c2"].into_iter().collect();
2396 let empty: BTreeSet<&str> = BTreeSet::new();
2397
2398 assert_eq!(
2400 adopt_decision(&["x", "y"], &empty, true, false),
2401 AdoptDecision::PinFresh
2402 );
2403 assert_eq!(
2405 adopt_decision(&["c1"], &owned, false, false),
2406 AdoptDecision::SkipPin
2407 );
2408 assert_eq!(
2409 adopt_decision(&["c1"], &owned, false, true),
2410 AdoptDecision::SkipPin
2411 );
2412 assert_eq!(
2414 adopt_decision(&["c1", "z"], &owned, true, false),
2415 AdoptDecision::PinAdopt
2416 );
2417 assert_eq!(
2419 adopt_decision(&["z1", "z2"], &owned, true, false),
2420 AdoptDecision::Abort
2421 );
2422 assert_eq!(
2423 adopt_decision(&["z1", "z2"], &owned, true, true),
2424 AdoptDecision::AdoptForced
2425 );
2426
2427 assert!(AdoptDecision::AdoptForced.is_additive());
2429 for decision in [
2430 AdoptDecision::PinFresh,
2431 AdoptDecision::PinAdopt,
2432 AdoptDecision::Abort,
2433 AdoptDecision::SkipPin,
2434 ] {
2435 assert!(!decision.is_additive());
2436 }
2437 }
2438
2439 #[test]
2440 fn older_store_without_owner_loads_as_none_and_pinned_roundtrips() {
2441 let json = r#"{"nodes":{},"edges":[]}"#;
2443 let store: LineageStore = serde_json::from_str(json).unwrap();
2444 assert!(store.owner().is_none());
2445 let value = serde_json::to_value(&store).unwrap();
2447 assert!(value.get("owner").is_none());
2448
2449 let mut pinned = LineageStore::new();
2451 pinned.pin_owner(owner("user_a", "Alice"));
2452 let back: LineageStore =
2453 serde_json::from_str(&serde_json::to_string(&pinned).unwrap()).unwrap();
2454 assert_eq!(back, pinned);
2455 assert_eq!(back.owner().unwrap().user_id, "user_a");
2456 }
2457
2458 #[test]
2459 fn on_disk_slugs_are_byte_identical_to_the_legacy_string_literals() {
2460 let mut store = LineageStore::new();
2463 store.update(&chain_clips(), &chain_resolution(), "now");
2464
2465 let value = serde_json::to_value(&store).unwrap();
2466 let edges = value.get("edges").unwrap().as_array().unwrap();
2467
2468 let primary_edge = edges
2470 .iter()
2471 .find(|e| e.get("child_id").unwrap() == "c")
2472 .unwrap();
2473 assert_eq!(primary_edge.get("role").unwrap(), "primary");
2474 assert_eq!(primary_edge.get("status").unwrap(), "active");
2475 assert_eq!(primary_edge.get("edge_type").unwrap(), "cover");
2476
2477 let node = value.get("nodes").unwrap().get("c").unwrap();
2479 assert_eq!(node.get("status").unwrap(), "observed");
2480
2481 let cache = value.get("resolution_cache").unwrap();
2483 assert_eq!(cache.get("a").unwrap().get("status").unwrap(), "resolved");
2484
2485 let mut store2 = LineageStore::new();
2487 let child = Clip {
2488 id: "x".into(),
2489 ..Default::default()
2490 };
2491 let mut roots = HashMap::new();
2492 roots.insert(
2493 "x".to_owned(),
2494 RootInfo {
2495 root_id: "ext".into(),
2496 root_title: String::new(),
2497 status: ResolveStatus::External,
2498 },
2499 );
2500 store2.update(
2501 std::slice::from_ref(&child),
2502 &Resolution {
2503 roots,
2504 gap_filled: Vec::new(),
2505 bridges: Vec::new(),
2506 },
2507 "now",
2508 );
2509 let v2 = serde_json::to_value(&store2).unwrap();
2510 assert_eq!(
2511 v2.get("resolution_cache")
2512 .unwrap()
2513 .get("x")
2514 .unwrap()
2515 .get("status")
2516 .unwrap(),
2517 "external"
2518 );
2519 }
2520
2521 #[test]
2522 fn serde_roundtrip_is_byte_identical() {
2523 let mut store = LineageStore::new();
2526 store.update(&chain_clips(), &chain_resolution(), "now");
2527
2528 let first = serde_json::to_string(&store).unwrap();
2529 let back: LineageStore = serde_json::from_str(&first).unwrap();
2530 let second = serde_json::to_string(&back).unwrap();
2531 assert_eq!(first, second, "round-trip must be byte-identical");
2532 }
2533
2534 #[test]
2535 fn existing_string_form_json_deserialises_correctly() {
2536 let json = r#"{
2539 "nodes": {"a": {"title": "Root", "status": "observed"}},
2540 "edges": [{"child_id": "b", "parent_id": "a", "role": "primary", "status": "active", "edge_type": "cover"}],
2541 "resolution_cache": {"b": {"root_id": "a", "status": "resolved"}}
2542 }"#;
2543 let store: LineageStore = serde_json::from_str(json).unwrap();
2544 assert_eq!(store.node("a").unwrap().status, NodeStatus::Observed);
2545 assert_eq!(store.edges[0].role, EdgeRole::Primary);
2546 assert_eq!(store.edges[0].status, EdgeStatus::Active);
2547 assert_eq!(store.get_root("b").unwrap().status, ResolveStatus::Resolved);
2548 let archived = store.archived_parents();
2550 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2551 }
2552
2553 #[test]
2554 fn lineage_resolution_and_album_grouping_are_unchanged() {
2555 let mut store = LineageStore::new();
2558 store.update(&chain_clips(), &chain_resolution(), "now");
2559
2560 for id in ["a", "b", "c"] {
2562 let cached = store.get_root(id).unwrap();
2563 assert_eq!(cached.root_id, "a");
2564 assert_eq!(cached.status, ResolveStatus::Resolved);
2565 }
2566
2567 for id in ["a", "b", "c"] {
2569 assert_eq!(store.album_for_id(id), "Root");
2570 }
2571
2572 let archived = store.archived_parents();
2574 assert_eq!(archived.get("c").map(String::as_str), Some("b"));
2575 assert_eq!(archived.get("b").map(String::as_str), Some("a"));
2576 assert!(!archived.contains_key("a"));
2577
2578 let cover = &chain_clips()[0];
2580 let ctx = store.context_for(cover);
2581 assert_eq!(ctx.root_id, "a");
2582 assert_eq!(ctx.status, ResolveStatus::Resolved);
2583 assert_eq!(ctx.album(&cover.title), "Root");
2584 }
2585}