use super::*;
use crate::reconcile::playlist::delete_playlist_artifact_action;
#[test]
fn playlist_missing_on_disk_forces_rewrite() {
let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
let mut local: HashMap<String, LocalFile> = HashMap::new();
local.insert("Mix.m3u8".to_owned(), LocalFile::default());
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
assert_eq!(actions.len(), 1, "missing playlist file must be rewritten");
assert!(matches!(
&actions[0],
Action::WriteArtifact {
kind: ArtifactKind::Playlist,
..
}
));
}
#[test]
fn playlist_present_on_disk_no_churn() {
let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
let mut local: HashMap<String, LocalFile> = HashMap::new();
local.insert("Mix.m3u8".to_owned(), present(200));
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &local);
assert!(
actions.is_empty(),
"present playlist with matching hash must not churn"
);
}
fn pl_desired(id: &str, name: &str, path: &str, hash: &str) -> PlaylistDesired {
PlaylistDesired {
id: id.to_owned(),
name: name.to_owned(),
path: path.to_owned(),
content: format!("#EXTM3U\n#PLAYLIST:{name}\n<{hash}>\n"),
hash: hash.to_owned(),
}
}
fn pl_state(name: &str, path: &str, hash: &str) -> PlaylistState {
PlaylistState {
name: name.to_owned(),
path: path.to_owned(),
hash: hash.to_owned(),
}
}
fn pl_store(entries: &[(&str, PlaylistState)]) -> BTreeMap<String, PlaylistState> {
entries
.iter()
.map(|(id, state)| ((*id).to_owned(), state.clone()))
.collect()
}
#[test]
fn playlist_write_emitted_for_a_new_playlist() {
let desired = vec![pl_desired("pl1", "Road Trip", "Road Trip.m3u8", "h1")];
let actions = plan_playlist_artifacts(&desired, &BTreeMap::new(), true, true, &HashMap::new());
assert_eq!(
actions,
vec![Action::WriteArtifact {
kind: ArtifactKind::Playlist,
path: "Road Trip.m3u8".to_owned(),
source_url: String::new(),
hash: "h1".to_owned(),
owner_id: "pl1".to_owned(),
content: Some("#EXTM3U\n#PLAYLIST:Road Trip\n<h1>\n".to_owned()),
}]
);
}
#[test]
fn playlist_write_emitted_when_hash_changes() {
let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h2")];
let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { hash, owner_id, .. } if hash == "h2" && owner_id == "pl1"
));
}
#[test]
fn playlist_unchanged_is_idempotent() {
let desired = vec![pl_desired("pl1", "Mix", "Mix.m3u8", "h1")];
let stored = pl_store(&[("pl1", pl_state("Mix", "Mix.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
assert!(actions.is_empty(), "an unchanged playlist plans nothing");
}
#[test]
fn playlist_rename_writes_new_and_deletes_old_path() {
let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
assert_eq!(
actions,
vec![
Action::WriteArtifact {
kind: ArtifactKind::Playlist,
path: "Summer.m3u8".to_owned(),
source_url: String::new(),
hash: "h2".to_owned(),
owner_id: "pl1".to_owned(),
content: Some("#EXTM3U\n#PLAYLIST:Summer\n<h2>\n".to_owned()),
},
Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: "Spring.m3u8".to_owned(),
owner_id: "pl1".to_owned(),
},
]
);
}
#[test]
fn playlist_rename_keeps_old_file_when_deletes_disallowed() {
let desired = vec![pl_desired("pl1", "Summer", "Summer.m3u8", "h2")];
let stored = pl_store(&[("pl1", pl_state("Spring", "Spring.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, false, true, &HashMap::new());
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { path, .. } if path == "Summer.m3u8"
));
assert!(
!actions
.iter()
.any(|a| matches!(a, Action::DeleteArtifact { .. })),
"old path must not be deleted when deletes are disallowed"
);
}
#[test]
fn playlist_stale_removed_only_under_full_gate() {
let stored = pl_store(&[("gone", pl_state("Gone", "Gone.m3u8", "h1"))]);
let deleted = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
assert_eq!(
deleted,
vec![Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: "Gone.m3u8".to_owned(),
owner_id: "gone".to_owned(),
}]
);
assert!(plan_playlist_artifacts(&[], &stored, false, true, &HashMap::new()).is_empty());
assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
assert!(plan_playlist_artifacts(&[], &stored, false, false, &HashMap::new()).is_empty());
}
#[test]
fn b2_failed_list_emits_zero_writes_and_zero_deletes() {
let stored = pl_store(&[
("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
]);
let actions = plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new());
assert!(
actions.is_empty(),
"a failed playlist listing must plan zero actions, got {actions:?}"
);
}
#[test]
fn b2_empty_list_deletes_only_when_fully_enumerated() {
let stored = pl_store(&[
("pl1", pl_state("Mix", "Mix.m3u8", "h1")),
("pl2", pl_state("Chill", "Chill.m3u8", "h2")),
]);
assert!(plan_playlist_artifacts(&[], &stored, true, false, &HashMap::new()).is_empty());
let wiped = plan_playlist_artifacts(&[], &stored, true, true, &HashMap::new());
assert_eq!(
wiped
.iter()
.filter(|a| matches!(a, Action::DeleteArtifact { .. }))
.count(),
2
);
}
#[test]
fn b2_failed_member_playlist_is_untouched_while_others_reconcile() {
let desired = vec![pl_desired("pl_ok", "Ok", "Ok.m3u8", "h2")];
let stored = pl_store(&[("pl_ok", pl_state("Ok", "Ok.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
assert_eq!(actions.len(), 1);
assert!(matches!(
&actions[0],
Action::WriteArtifact { owner_id, .. } if owner_id == "pl_ok"
));
assert!(
!actions.iter().any(|a| match a {
Action::WriteArtifact { owner_id, .. } | Action::DeleteArtifact { owner_id, .. } =>
owner_id == "pl_fail",
_ => false,
}),
"a protected (failed-member) playlist must have no action"
);
}
#[test]
fn playlist_rename_collision_downgrades_the_delete() {
let desired = vec![
pl_desired("pl1", "Shared", "Shared.m3u8", "h2"),
pl_desired("pl2", "Shared", "Shared.m3u8", "h3"),
];
let stored = pl_store(&[("pl1", pl_state("Old", "Shared.m3u8", "h1"))]);
let actions = plan_playlist_artifacts(&desired, &stored, true, true, &HashMap::new());
let write_paths: BTreeSet<&str> = actions
.iter()
.filter_map(|a| match a {
Action::WriteArtifact { path, .. } => Some(path.as_str()),
_ => None,
})
.collect();
for a in &actions {
if let Action::DeleteArtifact { path, .. } = a {
assert!(
!write_paths.contains(path.as_str()),
"a playlist delete aliases a write target: {path}"
);
}
}
}
#[test]
fn delete_playlist_artifact_gate_needs_both_gates_and_a_path() {
assert_eq!(
delete_playlist_artifact_action("pl1", "Mix.m3u8", true, true),
Some(Action::DeleteArtifact {
kind: ArtifactKind::Playlist,
path: "Mix.m3u8".to_owned(),
owner_id: "pl1".to_owned(),
})
);
assert_eq!(
delete_playlist_artifact_action("pl1", "Mix.m3u8", false, true),
None
);
assert_eq!(
delete_playlist_artifact_action("pl1", "Mix.m3u8", true, false),
None
);
assert_eq!(delete_playlist_artifact_action("pl1", "", true, true), None);
}