1use std::collections::{BTreeSet, HashMap, HashSet};
8
9use crate::desired::{LIKED_PLAYLIST_ID, PlaylistInput, build_playlist_desired};
10use crate::graph::LineageStore;
11use crate::model::Clip;
12use crate::reconcile::{
13 Desired, PlaylistDesired, SourceStatus, area_authoritative, area_fully_enumerated,
14 deletion_allowed,
15};
16use crate::vocab::SourceMode;
17
18pub struct AreaListing {
27 kind: AreaKind,
28 mode: SourceMode,
30 clips: Vec<Clip>,
32 authoritative_ignoring_empty: bool,
36}
37
38pub enum AreaKind {
41 Library,
42 Liked,
43 Playlist { id: String, name: String },
44}
45
46impl AreaListing {
47 pub fn listed(
53 kind: AreaKind,
54 mode: SourceMode,
55 clips: Vec<Clip>,
56 complete: bool,
57 any_filtered: bool,
58 narrowed: bool,
59 ) -> Self {
60 Self {
61 kind,
62 mode,
63 clips,
64 authoritative_ignoring_empty: area_authoritative(complete, any_filtered, narrowed),
65 }
66 }
67
68 pub fn failed(kind: AreaKind, mode: SourceMode) -> Self {
71 Self {
72 kind,
73 mode,
74 clips: Vec::new(),
75 authoritative_ignoring_empty: false,
76 }
77 }
78
79 pub fn unresolved_playlist(mode: SourceMode) -> Self {
81 Self::failed(
82 AreaKind::Playlist {
83 id: String::new(),
84 name: String::new(),
85 },
86 mode,
87 )
88 }
89
90 pub fn clips(&self) -> &[Clip] {
92 &self.clips
93 }
94}
95
96pub fn area_mode(area: &AreaListing, force_copy: bool) -> SourceMode {
98 if force_copy {
99 SourceMode::Copy
100 } else {
101 area.mode
102 }
103}
104
105#[must_use]
108pub fn area_enumerated(area: &AreaListing, force_copy: bool) -> bool {
109 area_fully_enumerated(
110 area.authoritative_ignoring_empty,
111 area.clips.is_empty(),
112 area_mode(area, force_copy),
113 )
114}
115
116#[must_use]
119pub fn library_authoritative(areas: &[AreaListing], force_copy: bool) -> bool {
120 areas
121 .iter()
122 .any(|a| matches!(a.kind, AreaKind::Library) && area_enumerated(a, force_copy))
123}
124
125#[must_use]
127pub fn source_statuses(areas: &[AreaListing], force_copy: bool) -> Vec<SourceStatus> {
128 areas
129 .iter()
130 .map(|area| SourceStatus {
131 mode: area_mode(area, force_copy),
132 fully_enumerated: area_enumerated(area, force_copy),
133 })
134 .collect()
135}
136
137#[must_use]
146pub fn adoption_enumerated(areas: &[AreaListing], force_copy: bool) -> bool {
147 library_authoritative(areas, force_copy)
148 || deletion_allowed(&source_statuses(areas, force_copy))
149}
150
151pub fn union_clips(areas: &[AreaListing]) -> Vec<Clip> {
154 let mut seen: HashSet<String> = HashSet::new();
155 let mut union: Vec<Clip> = Vec::new();
156 for area in areas {
157 for clip in &area.clips {
158 if seen.insert(clip.id.clone()) {
159 union.push(clip.clone());
160 }
161 }
162 }
163 union
164}
165
166#[must_use]
172pub fn playlists_enumerated(areas: &[AreaListing], force_copy: bool, members_intact: bool) -> bool {
173 members_intact
174 && areas.iter().all(|area| match area.kind {
175 AreaKind::Library => true,
176 AreaKind::Liked | AreaKind::Playlist { .. } => area_enumerated(area, force_copy),
177 })
178}
179
180pub fn build_scoped_playlist_desired(
192 areas: &[AreaListing],
193 desired: &[Desired],
194 store: &LineageStore,
195 protected: &mut BTreeSet<String>,
196 force_copy: bool,
197 members_intact: bool,
198) -> (Vec<PlaylistDesired>, bool) {
199 let mut owned: Vec<(String, String, Vec<Clip>)> = Vec::new();
200 for area in areas {
201 match &area.kind {
202 AreaKind::Playlist { id, name } => {
203 if !id.is_empty() && area_enumerated(area, force_copy) {
204 owned.push((id.clone(), name.clone(), area.clips.clone()));
205 } else if !id.is_empty() {
206 protected.insert(id.clone());
207 }
208 }
209 AreaKind::Liked => {
210 if area_enumerated(area, force_copy) {
211 owned.push((
212 LIKED_PLAYLIST_ID.to_owned(),
213 "Liked Songs".to_owned(),
214 area.clips.clone(),
215 ));
216 } else {
217 protected.insert(LIKED_PLAYLIST_ID.to_owned());
218 }
219 }
220 AreaKind::Library => {}
221 }
222 }
223 let rendered: BTreeSet<&str> = owned.iter().map(|(id, _, _)| id.as_str()).collect();
224 for id in store.playlists.keys() {
227 if !rendered.contains(id.as_str()) {
228 protected.insert(id.clone());
229 }
230 }
231 let inputs: Vec<PlaylistInput<'_>> = owned
232 .iter()
233 .map(|(id, name, members)| PlaylistInput {
234 id: id.as_str(),
235 name: name.as_str(),
236 members: members.as_slice(),
237 })
238 .collect();
239 (
240 build_playlist_desired(&inputs, desired),
241 playlists_enumerated(areas, force_copy, members_intact),
242 )
243}
244
245pub fn build_modes_by_id(
254 areas: &[AreaListing],
255 force_copy: bool,
256) -> HashMap<String, Vec<SourceMode>> {
257 let mut map: HashMap<String, (bool, bool)> = HashMap::new();
258 for area in areas {
259 let mode = area_mode(area, force_copy);
260 for clip in &area.clips {
261 let entry = map.entry(clip.id.clone()).or_insert((false, false));
262 match mode {
263 SourceMode::Mirror => entry.0 = true,
264 SourceMode::Copy => entry.1 = true,
265 }
266 }
267 }
268 map.into_iter()
269 .map(|(id, (mirror, copy))| {
270 let mut modes = Vec::new();
271 if mirror {
272 modes.push(SourceMode::Mirror);
273 }
274 if copy {
275 modes.push(SourceMode::Copy);
276 }
277 (id, modes)
278 })
279 .collect()
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::{
286 Action, ArtifactToggles, AudioFormat, LocalFile, Manifest, ManifestEntry, NamingConfig,
287 NamingScope, build_desired, narrows_downloads, reconcile,
288 };
289
290 fn tclip(id: &str) -> Clip {
291 Clip {
292 id: id.to_owned(),
293 title: "Song".to_owned(),
294 handle: "alice".to_owned(),
295 ..Default::default()
296 }
297 }
298
299 fn area(kind: AreaKind, mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
300 AreaListing {
301 kind,
302 mode,
303 clips: ids.iter().map(|id| tclip(id)).collect(),
304 authoritative_ignoring_empty: authoritative,
305 }
306 }
307
308 #[test]
312 fn empty_mirror_area_is_not_enumerated() {
313 let mirror = area(AreaKind::Liked, SourceMode::Mirror, &[], true);
314 assert!(!area_enumerated(&mirror, false));
315 let copy = area(AreaKind::Liked, SourceMode::Copy, &[], true);
316 assert!(area_enumerated(©, false));
317 let full = area(AreaKind::Liked, SourceMode::Mirror, &["x"], true);
319 assert!(area_enumerated(&full, false));
320 }
321
322 #[test]
326 fn adoption_enumerated_covers_a_mirror_playlist_under_library_off() {
327 let playlist = |mode, ids: &[&str], auth| {
328 area(
329 AreaKind::Playlist {
330 id: "p".into(),
331 name: "P".into(),
332 },
333 mode,
334 ids,
335 auth,
336 )
337 };
338 assert!(adoption_enumerated(
340 &[playlist(SourceMode::Mirror, &["pl"], true)],
341 false
342 ));
343 assert!(!adoption_enumerated(
345 &[playlist(SourceMode::Copy, &["pl"], true)],
346 false
347 ));
348 assert!(!adoption_enumerated(
350 &[playlist(SourceMode::Mirror, &[], true)],
351 false
352 ));
353 assert!(!adoption_enumerated(
355 &[playlist(SourceMode::Mirror, &["pl"], false)],
356 false
357 ));
358 assert!(!adoption_enumerated(
360 &[playlist(SourceMode::Mirror, &["pl"], true)],
361 true
362 ));
363 assert!(adoption_enumerated(
365 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
366 false,
367 ));
368 }
369
370 #[test]
373 fn library_authoritative_counts_protector_not_off() {
374 let with_protector = vec![
375 area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
376 area(
377 AreaKind::Playlist {
378 id: "p".into(),
379 name: "P".into(),
380 },
381 SourceMode::Mirror,
382 &["pl"],
383 true,
384 ),
385 ];
386 assert!(library_authoritative(&with_protector, false));
387
388 let off = vec![area(
389 AreaKind::Playlist {
390 id: "p".into(),
391 name: "P".into(),
392 },
393 SourceMode::Mirror,
394 &["pl"],
395 true,
396 )];
397 assert!(!library_authoritative(&off, false));
398 }
399
400 fn verdict(areas: &[AreaListing]) -> (bool, bool, bool) {
403 let can_delete = deletion_allowed(&source_statuses(areas, false));
404 let lib_auth = library_authoritative(areas, false);
405 (
406 can_delete,
407 lib_auth,
408 narrows_downloads(can_delete, lib_auth),
409 )
410 }
411
412 fn pl_area(mode: SourceMode, ids: &[&str], authoritative: bool) -> AreaListing {
413 area(
414 AreaKind::Playlist {
415 id: "p".into(),
416 name: "P".into(),
417 },
418 mode,
419 ids,
420 authoritative,
421 )
422 }
423
424 #[test]
427 fn narrowed_playlist_mirror_disarms_deletion() {
428 let narrowed = pl_area(
429 SourceMode::Mirror,
430 &["a"],
431 area_authoritative(true, false, true),
432 );
433 assert!(!area_enumerated(&narrowed, false));
434 assert!(!deletion_allowed(&source_statuses(&[narrowed], false)));
435
436 let full = pl_area(
437 SourceMode::Mirror,
438 &["a"],
439 area_authoritative(true, false, false),
440 );
441 assert!(area_enumerated(&full, false));
442 assert!(deletion_allowed(&source_statuses(&[full], false)));
443 }
444
445 #[test]
449 fn narrowed_playlist_with_protector_neither_deletes_nor_narrows() {
450 let areas = vec![
451 area(AreaKind::Library, SourceMode::Copy, &["lib"], true),
452 pl_area(
453 SourceMode::Mirror,
454 &["pl"],
455 area_authoritative(true, false, true),
456 ),
457 ];
458 let (can_delete, lib_auth, truncate) = verdict(&areas);
459 assert!(!can_delete, "narrowed playlist mirror is disarmed");
460 assert!(lib_auth, "the protector is an authoritative library");
461 assert!(
462 !truncate,
463 "the full library is listed, so downloads are not narrowed"
464 );
465 }
466
467 #[test]
471 fn narrowed_playlist_off_disarms_and_narrows() {
472 let areas = vec![pl_area(
473 SourceMode::Mirror,
474 &["pl"],
475 area_authoritative(true, false, true),
476 )];
477 let (can_delete, lib_auth, truncate) = verdict(&areas);
478 assert!(!can_delete, "narrowed playlist mirror is disarmed");
479 assert!(!lib_auth, "library=off leaves no library area");
480 assert!(
481 truncate,
482 "no armed deletion and no full library, so downloads narrow"
483 );
484 }
485
486 #[test]
491 fn configured_full_library_mirror_still_deletes_when_narrowed() {
492 let areas = vec![area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)];
493 let (can_delete, lib_auth, truncate) = verdict(&areas);
494 assert!(
495 can_delete,
496 "the configured full-library mirror still deletes"
497 );
498 assert!(lib_auth);
499 assert!(
500 !truncate,
501 "the full library is listed, so downloads are not narrowed"
502 );
503 }
504
505 #[test]
509 fn adoption_skips_pin_on_a_narrowed_library_off_playlist() {
510 let areas = vec![pl_area(
511 SourceMode::Mirror,
512 &["pl"],
513 area_authoritative(true, false, true),
514 )];
515 assert!(!adoption_enumerated(&areas, false));
516 }
517
518 #[test]
521 fn union_keeps_first_area_payload() {
522 let mut lib = tclip("shared");
523 lib.title = "Library".to_owned();
524 let mut pl = tclip("shared");
525 pl.title = "Playlist".to_owned();
526 let areas = vec![
527 AreaListing {
528 kind: AreaKind::Library,
529 mode: SourceMode::Copy,
530 clips: vec![lib, tclip("lib-only")],
531 authoritative_ignoring_empty: true,
532 },
533 AreaListing {
534 kind: AreaKind::Playlist {
535 id: "p".into(),
536 name: "P".into(),
537 },
538 mode: SourceMode::Mirror,
539 clips: vec![pl],
540 authoritative_ignoring_empty: true,
541 },
542 ];
543 let union = union_clips(&areas);
544 assert_eq!(union.len(), 2);
545 assert_eq!(union[0].id, "shared");
546 assert_eq!(union[0].title, "Library");
547 assert_eq!(union[1].id, "lib-only");
548 }
549
550 #[test]
551 fn a_failed_area_suppresses_deletion_for_the_run() {
552 let areas = [
553 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
554 area(
556 AreaKind::Playlist {
557 id: "p".into(),
558 name: "P".into(),
559 },
560 SourceMode::Mirror,
561 &[],
562 false,
563 ),
564 ];
565 let sources: Vec<SourceStatus> = areas
566 .iter()
567 .map(|a| SourceStatus {
568 mode: area_mode(a, false),
569 fully_enumerated: area_enumerated(a, false),
570 })
571 .collect();
572 assert!(!deletion_allowed(&sources));
573 }
574
575 #[test]
579 fn mixed_mode_deletes_only_mirror_exclusive_orphans() {
580 let areas = vec![
581 area(AreaKind::Liked, SourceMode::Mirror, &["m-live"], true),
582 area(
583 AreaKind::Playlist {
584 id: "p".into(),
585 name: "P".into(),
586 },
587 SourceMode::Copy,
588 &["c-live"],
589 true,
590 ),
591 ];
592 let sources: Vec<SourceStatus> = areas
593 .iter()
594 .map(|a| SourceStatus {
595 mode: area_mode(a, false),
596 fully_enumerated: area_enumerated(a, false),
597 })
598 .collect();
599 assert!(deletion_allowed(&sources));
600
601 let modes = build_modes_by_id(&areas, false);
602 let union = union_clips(&areas);
603 let desired = build_desired(
604 &union.iter().collect::<Vec<_>>(),
605 AudioFormat::Flac,
606 &modes,
607 &HashMap::new(),
608 NamingScope {
609 config: &NamingConfig::default(),
610 colliding_albums: &BTreeSet::new(),
611 colliding_ids: &BTreeSet::new(),
612 },
613 ArtifactToggles::default(),
614 );
615
616 let mut manifest = Manifest::new();
617 for id in ["m-live", "c-live", "m-orphan", "c-orphan"] {
619 manifest.insert(
620 id,
621 ManifestEntry {
622 path: format!("{id}.flac"),
623 format: AudioFormat::Flac,
624 size: 100,
625 preserve: id == "c-orphan",
628 ..Default::default()
629 },
630 );
631 }
632 let local: HashMap<String, LocalFile> = manifest
633 .iter()
634 .map(|(id, _)| {
635 (
636 id.clone(),
637 LocalFile {
638 exists: true,
639 size: 100,
640 },
641 )
642 })
643 .collect();
644 let plan = reconcile(&manifest, &desired, &local, &sources);
645 let deleted: Vec<&str> = plan
646 .actions
647 .iter()
648 .filter_map(|a| match a {
649 Action::Delete { clip_id, .. } => Some(clip_id.as_str()),
650 _ => None,
651 })
652 .collect();
653 assert_eq!(deleted, vec!["m-orphan"]);
654 }
655
656 #[test]
659 fn build_modes_by_id_copy_wins_and_dedups() {
660 let areas = [
661 area(AreaKind::Liked, SourceMode::Mirror, &["a", "b"], true),
662 pl_area(SourceMode::Copy, &["b", "c"], true),
663 ];
664 let map = build_modes_by_id(&areas, false);
665 assert_eq!(map["a"], vec![SourceMode::Mirror]);
666 assert_eq!(map["b"], vec![SourceMode::Mirror, SourceMode::Copy]);
667 assert_eq!(map["c"], vec![SourceMode::Copy]);
668 }
669
670 #[test]
673 fn playlists_enumerated_gates_on_every_playlist_area() {
674 assert!(playlists_enumerated(
676 &[
677 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
678 pl_area(SourceMode::Mirror, &["b"], true),
679 ],
680 false,
681 true,
682 ));
683
684 assert!(!playlists_enumerated(
686 &[
687 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
688 AreaListing::unresolved_playlist(SourceMode::Mirror),
689 ],
690 false,
691 true,
692 ));
693
694 assert!(!playlists_enumerated(
696 &[
697 AreaListing::failed(AreaKind::Liked, SourceMode::Mirror),
698 pl_area(SourceMode::Mirror, &["b"], true),
699 ],
700 false,
701 true,
702 ));
703
704 assert!(!playlists_enumerated(
706 &[
707 area(AreaKind::Liked, SourceMode::Mirror, &["a"], true),
708 pl_area(SourceMode::Mirror, &["b"], true),
709 ],
710 false,
711 false,
712 ));
713 }
714
715 #[test]
718 fn playlists_enumerated_ignores_library_only_sets() {
719 assert!(playlists_enumerated(
720 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
721 false,
722 true,
723 ));
724 assert!(!playlists_enumerated(
725 &[area(AreaKind::Library, SourceMode::Mirror, &["lib"], true)],
726 false,
727 false,
728 ));
729 assert!(playlists_enumerated(&[], false, true));
730 assert!(!playlists_enumerated(&[], false, false));
731 }
732
733 fn desired_for(clips: &[&Clip]) -> Vec<Desired> {
739 let modes: HashMap<String, Vec<SourceMode>> = clips
740 .iter()
741 .map(|c| (c.id.clone(), vec![SourceMode::Copy]))
742 .collect();
743 build_desired(
744 clips,
745 AudioFormat::Flac,
746 &modes,
747 &HashMap::new(),
748 NamingScope {
749 config: &NamingConfig::default(),
750 colliding_albums: &BTreeSet::new(),
751 colliding_ids: &BTreeSet::new(),
752 },
753 ArtifactToggles::default(),
754 )
755 }
756
757 fn pl_area_named(
758 id: &str,
759 name: &str,
760 mode: SourceMode,
761 ids: &[&str],
762 authoritative: bool,
763 ) -> AreaListing {
764 area(
765 AreaKind::Playlist {
766 id: id.to_owned(),
767 name: name.to_owned(),
768 },
769 mode,
770 ids,
771 authoritative,
772 )
773 }
774
775 #[test]
776 fn additive_scoped_run_renders_selected_playlist() {
777 let ca = tclip("a");
782 let desired = desired_for(&[&ca]);
783 let areas = [pl_area_named(
784 "holiday",
785 "Holiday",
786 SourceMode::Copy,
787 &["a"],
788 true,
789 )];
790 let store = LineageStore::default();
791 let mut protected = BTreeSet::new();
792 let (rendered, delete_gate) =
793 build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, false);
794
795 assert_eq!(rendered.len(), 1, "the selected playlist is rendered");
796 assert_eq!(rendered[0].id, "holiday");
797 assert!(!protected.contains("holiday"), "it is owned, not protected");
798 assert!(
799 !delete_gate,
800 "the delete gate still tracks members_intact (false here)"
801 );
802 }
803
804 #[test]
805 fn narrowed_scoped_run_still_protects_playlist() {
806 let ca = tclip("a");
811 let desired = desired_for(&[&ca]);
812 let narrowed = pl_area_named(
813 "holiday",
814 "Holiday",
815 SourceMode::Copy,
816 &["a"],
817 area_authoritative(true, false, true),
818 );
819 let store = LineageStore::default();
820 let mut protected = BTreeSet::new();
821 let (rendered, _) = build_scoped_playlist_desired(
822 &[narrowed],
823 &desired,
824 &store,
825 &mut protected,
826 false,
827 false,
828 );
829 assert!(rendered.is_empty(), "a narrowed area is not rendered");
830 assert!(protected.contains("holiday"), "and its id is protected");
831 }
832
833 #[test]
834 fn failed_member_listing_still_protects() {
835 let ca = tclip("a");
837 let desired = desired_for(&[&ca]);
838 let store = LineageStore::default();
839
840 let mut protected = BTreeSet::new();
842 let (rendered, _) = build_scoped_playlist_desired(
843 &[AreaListing::unresolved_playlist(SourceMode::Copy)],
844 &desired,
845 &store,
846 &mut protected,
847 false,
848 false,
849 );
850 assert!(rendered.is_empty(), "an unresolved listing renders nothing");
851
852 let mut protected = BTreeSet::new();
854 let failed = AreaListing::failed(
855 AreaKind::Playlist {
856 id: "holiday".to_owned(),
857 name: "Holiday".to_owned(),
858 },
859 SourceMode::Copy,
860 );
861 let (rendered, _) = build_scoped_playlist_desired(
862 &[failed],
863 &desired,
864 &store,
865 &mut protected,
866 false,
867 false,
868 );
869 assert!(rendered.is_empty());
870 assert!(
871 protected.contains("holiday"),
872 "a failed listing is protected"
873 );
874 }
875
876 #[test]
877 fn liked_area_follows_the_same_rule() {
878 let ca = tclip("a");
882 let desired = desired_for(&[&ca]);
883 let store = LineageStore::default();
884
885 let liked = area(AreaKind::Liked, SourceMode::Copy, &["a"], true);
886 let mut protected = BTreeSet::new();
887 let (rendered, _) =
888 build_scoped_playlist_desired(&[liked], &desired, &store, &mut protected, false, false);
889 assert_eq!(rendered.len(), 1);
890 assert_eq!(rendered[0].id, LIKED_PLAYLIST_ID);
891 assert!(!protected.contains(LIKED_PLAYLIST_ID));
892
893 let failed = AreaListing::failed(AreaKind::Liked, SourceMode::Copy);
894 let mut protected = BTreeSet::new();
895 let (rendered, _) = build_scoped_playlist_desired(
896 &[failed],
897 &desired,
898 &store,
899 &mut protected,
900 false,
901 false,
902 );
903 assert!(rendered.is_empty());
904 assert!(protected.contains(LIKED_PLAYLIST_ID));
905 }
906
907 #[test]
908 fn scoped_render_shows_missing_member_as_comment() {
909 let ca = tclip("a");
913 let desired = desired_for(&[&ca]); let areas = [pl_area_named(
915 "holiday",
916 "Holiday",
917 SourceMode::Copy,
918 &["a", "b"],
919 true,
920 )];
921 let store = LineageStore::default();
922 let mut protected = BTreeSet::new();
923 let (rendered, _) =
924 build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, false);
925
926 assert_eq!(rendered.len(), 1);
927 let content = &rendered[0].content;
928 assert!(
929 content.contains("# (not in library)"),
930 "a missing member is a comment, not a dangling path"
931 );
932 assert!(
933 content.contains(&desired[0].path),
934 "the present member keeps its audio path"
935 );
936 }
937
938 #[test]
939 fn scoped_write_gate_decoupled_from_delete_gate() {
940 let ca = tclip("a");
944 let desired = desired_for(&[&ca]);
945 let areas = [pl_area_named(
946 "holiday",
947 "Holiday",
948 SourceMode::Copy,
949 &["a"],
950 true,
951 )];
952 let store = LineageStore::default();
953
954 let mut protected = BTreeSet::new();
955 let (rendered, gate) =
956 build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, false);
957 assert_eq!(rendered.len(), 1, "writes with members_intact = false");
958 assert!(!gate, "but authorises no delete");
959
960 let mut protected = BTreeSet::new();
961 let (rendered, gate) =
962 build_scoped_playlist_desired(&areas, &desired, &store, &mut protected, false, true);
963 assert_eq!(rendered.len(), 1);
964 assert!(gate, "an intact, enumerated run authorises deletes");
965 }
966}