#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "M175: queue position arithmetic — torrent counts fit u32"
)]
use irontide_core::Id20;
#[derive(Debug, Clone)]
pub(crate) struct QueueEntry {
pub info_hash: Id20,
pub position: i32,
}
#[allow(dead_code)] pub(crate) fn append_position(entries: &[QueueEntry]) -> i32 {
entries
.iter()
.map(|e| e.position)
.max()
.map_or(0, |m| m + 1)
}
pub(crate) fn remove_position(entries: &mut Vec<QueueEntry>, pos: i32) -> Vec<(Id20, i32, i32)> {
entries.retain(|e| e.position != pos);
let mut changed = Vec::new();
for entry in entries.iter_mut() {
if entry.position > pos {
let old = entry.position;
entry.position -= 1;
changed.push((entry.info_hash, old, entry.position));
}
}
changed
}
pub(crate) fn set_position(
entries: &mut [QueueEntry],
info_hash: Id20,
new_pos: i32,
) -> Vec<(Id20, i32, i32)> {
let new_pos = new_pos.clamp(0, entries.len().saturating_sub(1) as i32);
let old_pos = match entries.iter().find(|e| e.info_hash == info_hash) {
Some(e) => e.position,
None => return Vec::new(),
};
if old_pos == new_pos {
return Vec::new();
}
let mut changed = Vec::new();
if new_pos < old_pos {
for entry in entries.iter_mut() {
if entry.info_hash == info_hash {
changed.push((entry.info_hash, old_pos, new_pos));
entry.position = new_pos;
} else if entry.position >= new_pos && entry.position < old_pos {
let old = entry.position;
entry.position += 1;
changed.push((entry.info_hash, old, entry.position));
}
}
} else {
for entry in entries.iter_mut() {
if entry.info_hash == info_hash {
changed.push((entry.info_hash, old_pos, new_pos));
entry.position = new_pos;
} else if entry.position > old_pos && entry.position <= new_pos {
let old = entry.position;
entry.position -= 1;
changed.push((entry.info_hash, old, entry.position));
}
}
}
changed
}
pub(crate) fn move_up(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
let pos = match entries.iter().find(|e| e.info_hash == info_hash) {
Some(e) if e.position > 0 => e.position,
_ => return Vec::new(),
};
set_position(entries, info_hash, pos - 1)
}
pub(crate) fn move_down(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
let max_pos = entries.iter().map(|e| e.position).max().unwrap_or(0);
let pos = match entries.iter().find(|e| e.info_hash == info_hash) {
Some(e) if e.position < max_pos => e.position,
_ => return Vec::new(),
};
set_position(entries, info_hash, pos + 1)
}
pub(crate) fn move_top(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
set_position(entries, info_hash, 0)
}
pub(crate) fn move_bottom(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
let max_pos = entries.len().saturating_sub(1) as i32;
set_position(entries, info_hash, max_pos)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum QueueCategory {
Downloading,
Seeding,
Checking,
}
#[derive(Debug, Clone)]
pub(crate) struct QueueCandidate {
pub info_hash: Id20,
pub position: i32,
pub category: QueueCategory,
pub is_active: bool,
pub is_inactive: bool,
pub recently_started: bool,
pub seed_rank: Option<i32>,
}
#[derive(Debug, Clone)]
pub(crate) struct QueueConfig {
pub active_downloads: i32,
pub active_seeds: i32,
pub active_checking: i32,
pub active_limit: i32,
pub dont_count_slow: bool,
pub prefer_seeds: bool,
}
#[derive(Debug, Default)]
pub(crate) struct QueueDecision {
pub to_resume: Vec<Id20>,
pub to_pause: Vec<Id20>,
}
pub(crate) fn compute_seed_rank(num_complete: i32, num_incomplete: i32) -> i32 {
let seeders = i64::from(num_complete.max(0));
let leechers = i64::from(num_incomplete.max(0));
if leechers == 0 {
return 0;
}
if seeders == 0 {
return i32::MAX;
}
i32::try_from((leechers * 1000) / seeders).unwrap_or(i32::MAX)
}
pub(crate) fn evaluate(candidates: &[QueueCandidate], config: &QueueConfig) -> QueueDecision {
let mut decision = QueueDecision::default();
let mut checking: Vec<_> = candidates
.iter()
.filter(|c| c.category == QueueCategory::Checking)
.collect();
checking.sort_by_key(|c| c.position);
let mut downloads: Vec<_> = candidates
.iter()
.filter(|c| c.category == QueueCategory::Downloading)
.collect();
downloads.sort_by_key(|c| c.position);
let mut seeds: Vec<_> = candidates
.iter()
.filter(|c| c.category == QueueCategory::Seeding)
.collect();
seeds.sort_by(|a, b| {
let rank_a = a.seed_rank.unwrap_or(0);
let rank_b = b.seed_rank.unwrap_or(0);
rank_b.cmp(&rank_a).then(a.position.cmp(&b.position))
});
let mut total_active: i32 = 0;
evaluate_group(
&checking,
config.active_checking,
config.active_limit,
config.dont_count_slow,
&mut total_active,
&mut decision,
);
let dl_seed_groups: Vec<(&[&QueueCandidate], i32)> = if config.prefer_seeds {
vec![
(&seeds, config.active_seeds),
(&downloads, config.active_downloads),
]
} else {
vec![
(&downloads, config.active_downloads),
(&seeds, config.active_seeds),
]
};
for (group, limit) in dl_seed_groups {
evaluate_group(
group,
limit,
config.active_limit,
config.dont_count_slow,
&mut total_active,
&mut decision,
);
}
decision
}
fn evaluate_group(
group: &[&QueueCandidate],
limit: i32,
active_limit: i32,
dont_count_slow: bool,
total_active: &mut i32,
decision: &mut QueueDecision,
) {
let mut category_active: i32 = 0;
for candidate in group {
let counts_toward_limit = !(dont_count_slow && candidate.is_inactive);
if candidate.is_active {
if counts_toward_limit {
category_active += 1;
*total_active += 1;
}
let over_category = limit >= 0 && category_active > limit;
let over_total = active_limit >= 0 && *total_active > active_limit;
if (over_category || over_total) && !candidate.recently_started {
decision.to_pause.push(candidate.info_hash);
if counts_toward_limit {
category_active -= 1;
*total_active -= 1;
}
}
} else {
let under_category = limit < 0 || category_active < limit;
let under_total = active_limit < 0 || *total_active < active_limit;
if under_category && under_total {
decision.to_resume.push(candidate.info_hash);
if counts_toward_limit {
category_active += 1;
*total_active += 1;
}
}
}
}
}
pub(crate) fn apply_preemption(decision: &mut QueueDecision, candidates: &[QueueCandidate]) {
for category in [
QueueCategory::Downloading,
QueueCategory::Seeding,
QueueCategory::Checking,
] {
let cat_candidates: Vec<_> = candidates
.iter()
.filter(|c| c.category == category)
.collect();
let worst_active = cat_candidates
.iter()
.filter(|c| c.is_active && !c.recently_started)
.filter(|c| !decision.to_pause.contains(&c.info_hash))
.max_by_key(|c| c.position);
let best_queued = cat_candidates
.iter()
.filter(|c| !c.is_active)
.filter(|c| !decision.to_resume.contains(&c.info_hash))
.min_by_key(|c| c.position);
if let (Some(active), Some(queued)) = (worst_active, best_queued)
&& queued.position < active.position
{
decision.to_pause.push(active.info_hash);
decision.to_resume.push(queued.info_hash);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_hash(n: u8) -> Id20 {
Id20::from([n; 20])
}
fn make_entries(n: usize) -> Vec<QueueEntry> {
(0..n)
.map(|i| QueueEntry {
info_hash: make_hash(i as u8),
position: i as i32,
})
.collect()
}
fn positions(entries: &[QueueEntry]) -> Vec<(u8, i32)> {
let mut v: Vec<_> = entries
.iter()
.map(|e| (e.info_hash.as_ref()[0], e.position))
.collect();
v.sort_by_key(|&(_, pos)| pos);
v
}
#[test]
fn append_to_empty() {
assert_eq!(append_position(&[]), 0);
}
#[test]
fn append_to_existing() {
let entries = make_entries(3);
assert_eq!(append_position(&entries), 3);
}
#[test]
fn remove_middle_shifts_down() {
let mut entries = make_entries(4);
let changed = remove_position(&mut entries, 1);
assert_eq!(entries.len(), 3);
assert_eq!(positions(&entries), vec![(0, 0), (2, 1), (3, 2)]);
assert_eq!(changed.len(), 2);
}
#[test]
fn remove_last_no_shifts() {
let mut entries = make_entries(3);
let changed = remove_position(&mut entries, 2);
assert_eq!(entries.len(), 2);
assert_eq!(positions(&entries), vec![(0, 0), (1, 1)]);
assert!(changed.is_empty());
}
#[test]
fn set_position_move_up() {
let mut entries = make_entries(4);
let changed = set_position(&mut entries, make_hash(3), 1);
assert_eq!(positions(&entries), vec![(0, 0), (3, 1), (1, 2), (2, 3)]);
assert_eq!(changed.len(), 3);
}
#[test]
fn set_position_move_down() {
let mut entries = make_entries(4);
let changed = set_position(&mut entries, make_hash(0), 2);
assert_eq!(positions(&entries), vec![(1, 0), (2, 1), (0, 2), (3, 3)]);
assert_eq!(changed.len(), 3);
}
#[test]
fn set_position_same_is_noop() {
let mut entries = make_entries(3);
let changed = set_position(&mut entries, make_hash(1), 1);
assert!(changed.is_empty());
}
#[test]
fn move_up_from_zero_is_noop() {
let mut entries = make_entries(3);
let changed = move_up(&mut entries, make_hash(0));
assert!(changed.is_empty());
}
#[test]
fn move_up_swaps_adjacent() {
let mut entries = make_entries(3);
let changed = move_up(&mut entries, make_hash(2));
assert_eq!(positions(&entries), vec![(0, 0), (2, 1), (1, 2)]);
assert_eq!(changed.len(), 2);
}
#[test]
fn move_down_from_last_is_noop() {
let mut entries = make_entries(3);
let changed = move_down(&mut entries, make_hash(2));
assert!(changed.is_empty());
}
#[test]
fn move_top_sends_to_front() {
let mut entries = make_entries(4);
let _changed = move_top(&mut entries, make_hash(3));
assert_eq!(positions(&entries), vec![(3, 0), (0, 1), (1, 2), (2, 3)]);
}
#[test]
fn move_bottom_sends_to_end() {
let mut entries = make_entries(4);
let _changed = move_bottom(&mut entries, make_hash(0));
assert_eq!(positions(&entries), vec![(1, 0), (2, 1), (3, 2), (0, 3)]);
}
fn default_config() -> QueueConfig {
QueueConfig {
active_downloads: 3,
active_seeds: 5,
active_checking: 1,
active_limit: 500,
dont_count_slow: true,
prefer_seeds: false,
}
}
#[test]
fn evaluate_starts_up_to_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_downloads: 2,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_resume.len(), 2);
assert_eq!(decision.to_resume[0], make_hash(0));
assert_eq!(decision.to_resume[1], make_hash(1));
assert!(decision.to_pause.is_empty());
}
#[test]
fn evaluate_pauses_over_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_downloads: 2,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(decision.to_resume.is_empty());
assert_eq!(decision.to_pause.len(), 1);
assert_eq!(decision.to_pause[0], make_hash(2));
}
#[test]
fn evaluate_inactive_dont_count() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: true,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: true,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_downloads: 2,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(decision.to_resume.is_empty());
assert!(decision.to_pause.is_empty());
}
#[test]
fn evaluate_respects_active_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(10),
position: 0,
category: QueueCategory::Seeding,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(11),
position: 1,
category: QueueCategory::Seeding,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(12),
position: 2,
category: QueueCategory::Seeding,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_limit: 4,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_pause.len(), 1);
assert_eq!(decision.to_pause[0], make_hash(12));
}
#[test]
fn evaluate_unlimited_limits() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_downloads: -1,
active_seeds: -1,
active_checking: -1,
active_limit: -1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_resume.len(), 2);
assert!(decision.to_pause.is_empty());
}
#[test]
fn paused_torrents_excluded_from_candidates_never_resume() {
let paused_hash = make_hash(99);
let candidates = vec![QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
}];
let config = QueueConfig {
active_downloads: 5,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(!decision.to_resume.contains(&paused_hash));
assert!(!decision.to_pause.contains(&paused_hash));
}
#[test]
fn evaluate_resumes_queued_when_slots_open() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
dont_count_slow: false,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(decision.to_pause.is_empty());
assert_eq!(decision.to_resume, vec![make_hash(2)]);
}
#[test]
fn evaluate_queued_stays_queued_when_over_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(3),
position: 3,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
dont_count_slow: false,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(!decision.to_resume.contains(&make_hash(3)));
}
#[test]
fn evaluate_checking_separate_from_downloads() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(20),
position: 0,
category: QueueCategory::Checking,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_checking: 1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(
decision.to_pause.is_empty(),
"3 DLs at limit + 1 checking: nothing paused"
);
assert_eq!(
decision.to_resume,
vec![make_hash(20)],
"checking runs independently"
);
}
#[test]
fn evaluate_checking_respects_own_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(20),
position: 0,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(21),
position: 1,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_checking: 1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_pause, vec![make_hash(21)]);
}
#[test]
fn evaluate_checking_counts_toward_active_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(20),
position: 0,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(10),
position: 0,
category: QueueCategory::Seeding,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_limit: 2,
active_checking: 5,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_pause, vec![make_hash(10)]);
}
#[test]
fn evaluate_checking_processed_first() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(20),
position: 0,
category: QueueCategory::Checking,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_limit: 2,
active_checking: 1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(
decision.to_resume.contains(&make_hash(20)),
"checking resumed"
);
assert!(
decision.to_resume.contains(&make_hash(0)),
"first DL resumed"
);
assert!(
!decision.to_resume.contains(&make_hash(1)),
"second DL blocked by active_limit"
);
}
#[test]
fn evaluate_metadata_uses_checking_budget() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(20),
position: 0,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_checking: 3,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(
decision.to_pause.is_empty(),
"metadata in Checking doesn't consume DL slot"
);
assert!(decision.to_resume.is_empty());
}
#[test]
fn evaluate_metadata_respects_checking_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(20),
position: 0,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(21),
position: 1,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(22),
position: 2,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(23),
position: 3,
category: QueueCategory::Checking,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = QueueConfig {
active_checking: 3,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_pause, vec![make_hash(23)]);
}
#[test]
fn evaluate_recently_started_exempt_from_pause() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(3),
position: 3,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: true,
seed_rank: None,
},
];
let config = default_config();
let decision = evaluate(&candidates, &config);
assert!(
!decision.to_pause.contains(&make_hash(3)),
"recently_started torrent must not be paused"
);
}
#[test]
fn evaluate_recently_started_still_counts_toward_limit() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: true,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(3),
position: 3,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = default_config();
let decision = evaluate(&candidates, &config);
assert!(
!decision.to_resume.contains(&make_hash(3)),
"queued torrent blocked: recently_started holds the slot"
);
}
#[test]
fn evaluate_not_recently_started_can_be_paused() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(3),
position: 3,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let config = default_config();
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_pause, vec![make_hash(3)]);
}
#[test]
fn rate_based_inactive_uses_realtime_rate() {
let candidates = vec![QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: true,
recently_started: false,
seed_rank: None,
}];
let config = QueueConfig {
active_downloads: 1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert!(
decision.to_pause.is_empty(),
"inactive torrent exempt from pausing"
);
}
#[test]
fn evaluate_seed_rank_high_demand_first() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(10),
position: 0,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: Some(100),
},
QueueCandidate {
info_hash: make_hash(11),
position: 1,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: Some(5000),
},
QueueCandidate {
info_hash: make_hash(12),
position: 2,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: Some(200),
},
];
let config = QueueConfig {
active_seeds: 2,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(decision.to_resume.len(), 2);
assert_eq!(decision.to_resume[0], make_hash(11), "highest demand first");
assert_eq!(
decision.to_resume[1],
make_hash(12),
"second highest demand"
);
}
#[test]
fn evaluate_seed_rank_tie_breaks_by_position() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(10),
position: 0,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: Some(500),
},
QueueCandidate {
info_hash: make_hash(11),
position: 1,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: Some(500),
},
];
let config = QueueConfig {
active_seeds: 1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(
decision.to_resume,
vec![make_hash(10)],
"lower position wins on tie"
);
}
#[test]
fn evaluate_seed_rank_none_treated_as_zero() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(10),
position: 0,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(11),
position: 1,
category: QueueCategory::Seeding,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: Some(100),
},
];
let config = QueueConfig {
active_seeds: 1,
..default_config()
};
let decision = evaluate(&candidates, &config);
assert_eq!(
decision.to_resume,
vec![make_hash(11)],
"known demand beats unknown"
);
}
#[test]
fn compute_seed_rank_zero_seeders_returns_max() {
assert_eq!(compute_seed_rank(0, 5), i32::MAX);
}
#[test]
fn compute_seed_rank_zero_leechers_returns_zero() {
assert_eq!(compute_seed_rank(5, 0), 0);
}
#[test]
fn preemption_displaces_lower_priority() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(2),
position: 2,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(4),
position: 4,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let mut decision = QueueDecision::default();
apply_preemption(&mut decision, &candidates);
assert!(
decision.to_pause.contains(&make_hash(4)),
"position 4 paused"
);
assert!(
decision.to_resume.contains(&make_hash(2)),
"position 2 resumed"
);
}
#[test]
fn preemption_respects_recently_started() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(4),
position: 4,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: true,
seed_rank: None,
},
];
let mut decision = QueueDecision::default();
apply_preemption(&mut decision, &candidates);
assert!(
decision.to_pause.is_empty(),
"recently_started not displaced"
);
assert!(decision.to_resume.is_empty(), "no swap");
}
#[test]
fn preemption_noop_when_optimal() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(5),
position: 5,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let mut decision = QueueDecision::default();
apply_preemption(&mut decision, &candidates);
assert!(decision.to_pause.is_empty(), "no swaps needed");
assert!(decision.to_resume.is_empty(), "no swaps needed");
}
#[test]
fn preemption_single_swap_per_category() {
let candidates = vec![
QueueCandidate {
info_hash: make_hash(0),
position: 0,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(1),
position: 1,
category: QueueCategory::Downloading,
is_active: false,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(3),
position: 3,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(4),
position: 4,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
QueueCandidate {
info_hash: make_hash(5),
position: 5,
category: QueueCategory::Downloading,
is_active: true,
is_inactive: false,
recently_started: false,
seed_rank: None,
},
];
let mut decision = QueueDecision::default();
apply_preemption(&mut decision, &candidates);
assert_eq!(
decision.to_pause,
vec![make_hash(5)],
"only pos 5 displaced"
);
assert_eq!(
decision.to_resume,
vec![make_hash(0)],
"only pos 0 promoted"
);
}
}