1#![allow(
2 clippy::cast_possible_truncation,
3 clippy::cast_possible_wrap,
4 clippy::cast_sign_loss,
5 reason = "M175: queue position arithmetic — torrent counts fit u32"
6)]
7
8use irontide_core::Id20;
11
12#[derive(Debug, Clone)]
14pub struct QueueEntry {
15 pub info_hash: Id20,
17 pub position: i32,
19}
20
21#[allow(dead_code)] pub(crate) fn append_position(entries: &[QueueEntry]) -> i32 {
24 entries
25 .iter()
26 .map(|e| e.position)
27 .max()
28 .map_or(0, |m| m + 1)
29}
30
31pub fn remove_position(entries: &mut Vec<QueueEntry>, pos: i32) -> Vec<(Id20, i32, i32)> {
34 entries.retain(|e| e.position != pos);
35 let mut changed = Vec::new();
36 for entry in entries.iter_mut() {
37 if entry.position > pos {
38 let old = entry.position;
39 entry.position -= 1;
40 changed.push((entry.info_hash, old, entry.position));
41 }
42 }
43 changed
44}
45
46pub fn set_position(
49 entries: &mut [QueueEntry],
50 info_hash: Id20,
51 new_pos: i32,
52) -> Vec<(Id20, i32, i32)> {
53 let new_pos = new_pos.clamp(0, entries.len().saturating_sub(1) as i32);
54 let old_pos = match entries.iter().find(|e| e.info_hash == info_hash) {
55 Some(e) => e.position,
56 None => return Vec::new(),
57 };
58 if old_pos == new_pos {
59 return Vec::new();
60 }
61
62 let mut changed = Vec::new();
63
64 if new_pos < old_pos {
65 for entry in entries.iter_mut() {
67 if entry.info_hash == info_hash {
68 changed.push((entry.info_hash, old_pos, new_pos));
69 entry.position = new_pos;
70 } else if entry.position >= new_pos && entry.position < old_pos {
71 let old = entry.position;
72 entry.position += 1;
73 changed.push((entry.info_hash, old, entry.position));
74 }
75 }
76 } else {
77 for entry in entries.iter_mut() {
79 if entry.info_hash == info_hash {
80 changed.push((entry.info_hash, old_pos, new_pos));
81 entry.position = new_pos;
82 } else if entry.position > old_pos && entry.position <= new_pos {
83 let old = entry.position;
84 entry.position -= 1;
85 changed.push((entry.info_hash, old, entry.position));
86 }
87 }
88 }
89
90 changed
91}
92
93pub fn move_up(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
95 let pos = match entries.iter().find(|e| e.info_hash == info_hash) {
96 Some(e) if e.position > 0 => e.position,
97 _ => return Vec::new(),
98 };
99 set_position(entries, info_hash, pos - 1)
100}
101
102pub fn move_down(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
104 let max_pos = entries.iter().map(|e| e.position).max().unwrap_or(0);
105 let pos = match entries.iter().find(|e| e.info_hash == info_hash) {
106 Some(e) if e.position < max_pos => e.position,
107 _ => return Vec::new(),
108 };
109 set_position(entries, info_hash, pos + 1)
110}
111
112pub fn move_top(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
114 set_position(entries, info_hash, 0)
115}
116
117pub fn move_bottom(entries: &mut [QueueEntry], info_hash: Id20) -> Vec<(Id20, i32, i32)> {
119 let max_pos = entries.len().saturating_sub(1) as i32;
120 set_position(entries, info_hash, max_pos)
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum QueueCategory {
130 Downloading,
132 Seeding,
134 Checking,
136}
137
138#[derive(Debug, Clone)]
140pub struct QueueCandidate {
141 pub info_hash: Id20,
143 pub position: i32,
145 pub category: QueueCategory,
147 pub is_active: bool,
149 pub is_inactive: bool,
152 pub recently_started: bool,
154 pub seed_rank: Option<i32>,
156}
157
158#[derive(Debug, Clone)]
160pub struct QueueConfig {
161 pub active_downloads: i32,
163 pub active_seeds: i32,
165 pub active_checking: i32,
167 pub active_limit: i32,
169 pub dont_count_slow: bool,
172 pub prefer_seeds: bool,
174}
175
176#[derive(Debug, Default)]
178pub struct QueueDecision {
179 pub to_resume: Vec<Id20>,
181 pub to_pause: Vec<Id20>,
183}
184
185#[must_use]
191pub fn compute_seed_rank(num_complete: i32, num_incomplete: i32) -> i32 {
192 let seeders = i64::from(num_complete.max(0));
193 let leechers = i64::from(num_incomplete.max(0));
194 if leechers == 0 {
195 return 0;
196 }
197 if seeders == 0 {
198 return i32::MAX;
199 }
200 i32::try_from((leechers * 1000) / seeders).unwrap_or(i32::MAX)
201}
202
203#[must_use]
212pub fn evaluate(candidates: &[QueueCandidate], config: &QueueConfig) -> QueueDecision {
213 let mut decision = QueueDecision::default();
214
215 let mut checking: Vec<_> = candidates
216 .iter()
217 .filter(|c| c.category == QueueCategory::Checking)
218 .collect();
219 checking.sort_by_key(|c| c.position);
220
221 let mut downloads: Vec<_> = candidates
222 .iter()
223 .filter(|c| c.category == QueueCategory::Downloading)
224 .collect();
225 downloads.sort_by_key(|c| c.position);
226
227 let mut seeds: Vec<_> = candidates
228 .iter()
229 .filter(|c| c.category == QueueCategory::Seeding)
230 .collect();
231 seeds.sort_by(|a, b| {
232 let rank_a = a.seed_rank.unwrap_or(0);
233 let rank_b = b.seed_rank.unwrap_or(0);
234 rank_b.cmp(&rank_a).then(a.position.cmp(&b.position))
235 });
236
237 let mut total_active: i32 = 0;
238
239 evaluate_group(
241 &checking,
242 config.active_checking,
243 config.active_limit,
244 config.dont_count_slow,
245 &mut total_active,
246 &mut decision,
247 );
248
249 let dl_seed_groups: Vec<(&[&QueueCandidate], i32)> = if config.prefer_seeds {
250 vec![
251 (&seeds, config.active_seeds),
252 (&downloads, config.active_downloads),
253 ]
254 } else {
255 vec![
256 (&downloads, config.active_downloads),
257 (&seeds, config.active_seeds),
258 ]
259 };
260
261 for (group, limit) in dl_seed_groups {
262 evaluate_group(
263 group,
264 limit,
265 config.active_limit,
266 config.dont_count_slow,
267 &mut total_active,
268 &mut decision,
269 );
270 }
271
272 decision
273}
274
275fn evaluate_group(
276 group: &[&QueueCandidate],
277 limit: i32,
278 active_limit: i32,
279 dont_count_slow: bool,
280 total_active: &mut i32,
281 decision: &mut QueueDecision,
282) {
283 let mut category_active: i32 = 0;
284
285 for candidate in group {
286 let counts_toward_limit = !(dont_count_slow && candidate.is_inactive);
287
288 if candidate.is_active {
289 if counts_toward_limit {
290 category_active += 1;
291 *total_active += 1;
292 }
293 let over_category = limit >= 0 && category_active > limit;
294 let over_total = active_limit >= 0 && *total_active > active_limit;
295 if (over_category || over_total) && !candidate.recently_started {
296 decision.to_pause.push(candidate.info_hash);
297 if counts_toward_limit {
298 category_active -= 1;
299 *total_active -= 1;
300 }
301 }
302 } else {
303 let under_category = limit < 0 || category_active < limit;
304 let under_total = active_limit < 0 || *total_active < active_limit;
305 if under_category && under_total {
306 decision.to_resume.push(candidate.info_hash);
307 if counts_toward_limit {
308 category_active += 1;
309 *total_active += 1;
310 }
311 }
312 }
313 }
314}
315
316pub fn apply_preemption(decision: &mut QueueDecision, candidates: &[QueueCandidate]) {
320 for category in [
321 QueueCategory::Downloading,
322 QueueCategory::Seeding,
323 QueueCategory::Checking,
324 ] {
325 let cat_candidates: Vec<_> = candidates
326 .iter()
327 .filter(|c| c.category == category)
328 .collect();
329
330 let worst_active = cat_candidates
332 .iter()
333 .filter(|c| c.is_active && !c.recently_started)
334 .filter(|c| !decision.to_pause.contains(&c.info_hash))
335 .max_by_key(|c| c.position);
336
337 let best_queued = cat_candidates
339 .iter()
340 .filter(|c| !c.is_active)
341 .filter(|c| !decision.to_resume.contains(&c.info_hash))
342 .min_by_key(|c| c.position);
343
344 if let (Some(active), Some(queued)) = (worst_active, best_queued)
345 && queued.position < active.position
346 {
347 decision.to_pause.push(active.info_hash);
348 decision.to_resume.push(queued.info_hash);
349 }
350 }
351}
352
353#[cfg(test)]
358mod tests {
359 use super::*;
360
361 fn make_hash(n: u8) -> Id20 {
362 Id20::from([n; 20])
363 }
364
365 fn make_entries(n: usize) -> Vec<QueueEntry> {
366 (0..n)
367 .map(|i| QueueEntry {
368 info_hash: make_hash(i as u8),
369 position: i as i32,
370 })
371 .collect()
372 }
373
374 fn positions(entries: &[QueueEntry]) -> Vec<(u8, i32)> {
375 let mut v: Vec<_> = entries
376 .iter()
377 .map(|e| (e.info_hash.as_ref()[0], e.position))
378 .collect();
379 v.sort_by_key(|&(_, pos)| pos);
380 v
381 }
382
383 #[test]
386 fn append_to_empty() {
387 assert_eq!(append_position(&[]), 0);
388 }
389
390 #[test]
391 fn append_to_existing() {
392 let entries = make_entries(3);
393 assert_eq!(append_position(&entries), 3);
394 }
395
396 #[test]
397 fn remove_middle_shifts_down() {
398 let mut entries = make_entries(4);
399 let changed = remove_position(&mut entries, 1);
400 assert_eq!(entries.len(), 3);
401 assert_eq!(positions(&entries), vec![(0, 0), (2, 1), (3, 2)]);
402 assert_eq!(changed.len(), 2);
403 }
404
405 #[test]
406 fn remove_last_no_shifts() {
407 let mut entries = make_entries(3);
408 let changed = remove_position(&mut entries, 2);
409 assert_eq!(entries.len(), 2);
410 assert_eq!(positions(&entries), vec![(0, 0), (1, 1)]);
411 assert!(changed.is_empty());
412 }
413
414 #[test]
415 fn set_position_move_up() {
416 let mut entries = make_entries(4);
417 let changed = set_position(&mut entries, make_hash(3), 1);
418 assert_eq!(positions(&entries), vec![(0, 0), (3, 1), (1, 2), (2, 3)]);
419 assert_eq!(changed.len(), 3);
420 }
421
422 #[test]
423 fn set_position_move_down() {
424 let mut entries = make_entries(4);
425 let changed = set_position(&mut entries, make_hash(0), 2);
426 assert_eq!(positions(&entries), vec![(1, 0), (2, 1), (0, 2), (3, 3)]);
427 assert_eq!(changed.len(), 3);
428 }
429
430 #[test]
431 fn set_position_same_is_noop() {
432 let mut entries = make_entries(3);
433 let changed = set_position(&mut entries, make_hash(1), 1);
434 assert!(changed.is_empty());
435 }
436
437 #[test]
438 fn move_up_from_zero_is_noop() {
439 let mut entries = make_entries(3);
440 let changed = move_up(&mut entries, make_hash(0));
441 assert!(changed.is_empty());
442 }
443
444 #[test]
445 fn move_up_swaps_adjacent() {
446 let mut entries = make_entries(3);
447 let changed = move_up(&mut entries, make_hash(2));
448 assert_eq!(positions(&entries), vec![(0, 0), (2, 1), (1, 2)]);
449 assert_eq!(changed.len(), 2);
450 }
451
452 #[test]
453 fn move_down_from_last_is_noop() {
454 let mut entries = make_entries(3);
455 let changed = move_down(&mut entries, make_hash(2));
456 assert!(changed.is_empty());
457 }
458
459 #[test]
460 fn move_top_sends_to_front() {
461 let mut entries = make_entries(4);
462 let _changed = move_top(&mut entries, make_hash(3));
463 assert_eq!(positions(&entries), vec![(3, 0), (0, 1), (1, 2), (2, 3)]);
464 }
465
466 #[test]
467 fn move_bottom_sends_to_end() {
468 let mut entries = make_entries(4);
469 let _changed = move_bottom(&mut entries, make_hash(0));
470 assert_eq!(positions(&entries), vec![(1, 0), (2, 1), (3, 2), (0, 3)]);
471 }
472
473 fn default_config() -> QueueConfig {
476 QueueConfig {
477 active_downloads: 3,
478 active_seeds: 5,
479 active_checking: 1,
480 active_limit: 500,
481 dont_count_slow: true,
482 prefer_seeds: false,
483 }
484 }
485
486 #[test]
487 fn evaluate_starts_up_to_limit() {
488 let candidates = vec![
489 QueueCandidate {
490 info_hash: make_hash(0),
491 position: 0,
492 category: QueueCategory::Downloading,
493 is_active: false,
494 is_inactive: false,
495 recently_started: false,
496 seed_rank: None,
497 },
498 QueueCandidate {
499 info_hash: make_hash(1),
500 position: 1,
501 category: QueueCategory::Downloading,
502 is_active: false,
503 is_inactive: false,
504 recently_started: false,
505 seed_rank: None,
506 },
507 QueueCandidate {
508 info_hash: make_hash(2),
509 position: 2,
510 category: QueueCategory::Downloading,
511 is_active: false,
512 is_inactive: false,
513 recently_started: false,
514 seed_rank: None,
515 },
516 ];
517 let config = QueueConfig {
518 active_downloads: 2,
519 ..default_config()
520 };
521 let decision = evaluate(&candidates, &config);
522 assert_eq!(decision.to_resume.len(), 2);
523 assert_eq!(decision.to_resume[0], make_hash(0));
524 assert_eq!(decision.to_resume[1], make_hash(1));
525 assert!(decision.to_pause.is_empty());
526 }
527
528 #[test]
529 fn evaluate_pauses_over_limit() {
530 let candidates = vec![
531 QueueCandidate {
532 info_hash: make_hash(0),
533 position: 0,
534 category: QueueCategory::Downloading,
535 is_active: true,
536 is_inactive: false,
537 recently_started: false,
538 seed_rank: None,
539 },
540 QueueCandidate {
541 info_hash: make_hash(1),
542 position: 1,
543 category: QueueCategory::Downloading,
544 is_active: true,
545 is_inactive: false,
546 recently_started: false,
547 seed_rank: None,
548 },
549 QueueCandidate {
550 info_hash: make_hash(2),
551 position: 2,
552 category: QueueCategory::Downloading,
553 is_active: true,
554 is_inactive: false,
555 recently_started: false,
556 seed_rank: None,
557 },
558 ];
559 let config = QueueConfig {
560 active_downloads: 2,
561 ..default_config()
562 };
563 let decision = evaluate(&candidates, &config);
564 assert!(decision.to_resume.is_empty());
565 assert_eq!(decision.to_pause.len(), 1);
566 assert_eq!(decision.to_pause[0], make_hash(2));
567 }
568
569 #[test]
570 fn evaluate_inactive_dont_count() {
571 let candidates = vec![
572 QueueCandidate {
573 info_hash: make_hash(0),
574 position: 0,
575 category: QueueCategory::Downloading,
576 is_active: true,
577 is_inactive: true,
578 recently_started: false,
579 seed_rank: None,
580 },
581 QueueCandidate {
582 info_hash: make_hash(1),
583 position: 1,
584 category: QueueCategory::Downloading,
585 is_active: true,
586 is_inactive: true,
587 recently_started: false,
588 seed_rank: None,
589 },
590 QueueCandidate {
591 info_hash: make_hash(2),
592 position: 2,
593 category: QueueCategory::Downloading,
594 is_active: true,
595 is_inactive: false,
596 recently_started: false,
597 seed_rank: None,
598 },
599 ];
600 let config = QueueConfig {
601 active_downloads: 2,
602 ..default_config()
603 };
604 let decision = evaluate(&candidates, &config);
605 assert!(decision.to_resume.is_empty());
606 assert!(decision.to_pause.is_empty());
607 }
608
609 #[test]
610 fn evaluate_respects_active_limit() {
611 let candidates = vec![
612 QueueCandidate {
613 info_hash: make_hash(0),
614 position: 0,
615 category: QueueCategory::Downloading,
616 is_active: true,
617 is_inactive: false,
618 recently_started: false,
619 seed_rank: None,
620 },
621 QueueCandidate {
622 info_hash: make_hash(1),
623 position: 1,
624 category: QueueCategory::Downloading,
625 is_active: true,
626 is_inactive: false,
627 recently_started: false,
628 seed_rank: None,
629 },
630 QueueCandidate {
631 info_hash: make_hash(10),
632 position: 0,
633 category: QueueCategory::Seeding,
634 is_active: true,
635 is_inactive: false,
636 recently_started: false,
637 seed_rank: None,
638 },
639 QueueCandidate {
640 info_hash: make_hash(11),
641 position: 1,
642 category: QueueCategory::Seeding,
643 is_active: true,
644 is_inactive: false,
645 recently_started: false,
646 seed_rank: None,
647 },
648 QueueCandidate {
649 info_hash: make_hash(12),
650 position: 2,
651 category: QueueCategory::Seeding,
652 is_active: true,
653 is_inactive: false,
654 recently_started: false,
655 seed_rank: None,
656 },
657 ];
658 let config = QueueConfig {
659 active_limit: 4,
660 ..default_config()
661 };
662 let decision = evaluate(&candidates, &config);
663 assert_eq!(decision.to_pause.len(), 1);
664 assert_eq!(decision.to_pause[0], make_hash(12));
665 }
666
667 #[test]
668 fn evaluate_unlimited_limits() {
669 let candidates = vec![
670 QueueCandidate {
671 info_hash: make_hash(0),
672 position: 0,
673 category: QueueCategory::Downloading,
674 is_active: false,
675 is_inactive: false,
676 recently_started: false,
677 seed_rank: None,
678 },
679 QueueCandidate {
680 info_hash: make_hash(1),
681 position: 1,
682 category: QueueCategory::Downloading,
683 is_active: false,
684 is_inactive: false,
685 recently_started: false,
686 seed_rank: None,
687 },
688 ];
689 let config = QueueConfig {
690 active_downloads: -1,
691 active_seeds: -1,
692 active_checking: -1,
693 active_limit: -1,
694 ..default_config()
695 };
696 let decision = evaluate(&candidates, &config);
697 assert_eq!(decision.to_resume.len(), 2);
698 assert!(decision.to_pause.is_empty());
699 }
700
701 #[test]
702 fn paused_torrents_excluded_from_candidates_never_resume() {
703 let paused_hash = make_hash(99);
704 let candidates = vec![QueueCandidate {
705 info_hash: make_hash(0),
706 position: 0,
707 category: QueueCategory::Downloading,
708 is_active: true,
709 is_inactive: false,
710 recently_started: false,
711 seed_rank: None,
712 }];
713 let config = QueueConfig {
714 active_downloads: 5,
715 ..default_config()
716 };
717 let decision = evaluate(&candidates, &config);
718 assert!(!decision.to_resume.contains(&paused_hash));
719 assert!(!decision.to_pause.contains(&paused_hash));
720 }
721
722 #[test]
723 fn evaluate_resumes_queued_when_slots_open() {
724 let candidates = vec![
725 QueueCandidate {
726 info_hash: make_hash(0),
727 position: 0,
728 category: QueueCategory::Downloading,
729 is_active: true,
730 is_inactive: false,
731 recently_started: false,
732 seed_rank: None,
733 },
734 QueueCandidate {
735 info_hash: make_hash(1),
736 position: 1,
737 category: QueueCategory::Downloading,
738 is_active: true,
739 is_inactive: false,
740 recently_started: false,
741 seed_rank: None,
742 },
743 QueueCandidate {
744 info_hash: make_hash(2),
745 position: 2,
746 category: QueueCategory::Downloading,
747 is_active: false,
748 is_inactive: false,
749 recently_started: false,
750 seed_rank: None,
751 },
752 ];
753 let config = QueueConfig {
754 dont_count_slow: false,
755 ..default_config()
756 };
757 let decision = evaluate(&candidates, &config);
758 assert!(decision.to_pause.is_empty());
759 assert_eq!(decision.to_resume, vec![make_hash(2)]);
760 }
761
762 #[test]
763 fn evaluate_queued_stays_queued_when_over_limit() {
764 let candidates = vec![
765 QueueCandidate {
766 info_hash: make_hash(0),
767 position: 0,
768 category: QueueCategory::Downloading,
769 is_active: true,
770 is_inactive: false,
771 recently_started: false,
772 seed_rank: None,
773 },
774 QueueCandidate {
775 info_hash: make_hash(1),
776 position: 1,
777 category: QueueCategory::Downloading,
778 is_active: true,
779 is_inactive: false,
780 recently_started: false,
781 seed_rank: None,
782 },
783 QueueCandidate {
784 info_hash: make_hash(2),
785 position: 2,
786 category: QueueCategory::Downloading,
787 is_active: true,
788 is_inactive: false,
789 recently_started: false,
790 seed_rank: None,
791 },
792 QueueCandidate {
793 info_hash: make_hash(3),
794 position: 3,
795 category: QueueCategory::Downloading,
796 is_active: false,
797 is_inactive: false,
798 recently_started: false,
799 seed_rank: None,
800 },
801 ];
802 let config = QueueConfig {
803 dont_count_slow: false,
804 ..default_config()
805 };
806 let decision = evaluate(&candidates, &config);
807 assert!(!decision.to_resume.contains(&make_hash(3)));
808 }
809
810 #[test]
813 fn evaluate_checking_separate_from_downloads() {
814 let candidates = vec![
815 QueueCandidate {
816 info_hash: make_hash(0),
817 position: 0,
818 category: QueueCategory::Downloading,
819 is_active: true,
820 is_inactive: false,
821 recently_started: false,
822 seed_rank: None,
823 },
824 QueueCandidate {
825 info_hash: make_hash(1),
826 position: 1,
827 category: QueueCategory::Downloading,
828 is_active: true,
829 is_inactive: false,
830 recently_started: false,
831 seed_rank: None,
832 },
833 QueueCandidate {
834 info_hash: make_hash(2),
835 position: 2,
836 category: QueueCategory::Downloading,
837 is_active: true,
838 is_inactive: false,
839 recently_started: false,
840 seed_rank: None,
841 },
842 QueueCandidate {
843 info_hash: make_hash(20),
844 position: 0,
845 category: QueueCategory::Checking,
846 is_active: false,
847 is_inactive: false,
848 recently_started: false,
849 seed_rank: None,
850 },
851 ];
852 let config = QueueConfig {
853 active_checking: 1,
854 ..default_config()
855 };
856 let decision = evaluate(&candidates, &config);
857 assert!(
858 decision.to_pause.is_empty(),
859 "3 DLs at limit + 1 checking: nothing paused"
860 );
861 assert_eq!(
862 decision.to_resume,
863 vec![make_hash(20)],
864 "checking runs independently"
865 );
866 }
867
868 #[test]
869 fn evaluate_checking_respects_own_limit() {
870 let candidates = vec![
871 QueueCandidate {
872 info_hash: make_hash(20),
873 position: 0,
874 category: QueueCategory::Checking,
875 is_active: true,
876 is_inactive: false,
877 recently_started: false,
878 seed_rank: None,
879 },
880 QueueCandidate {
881 info_hash: make_hash(21),
882 position: 1,
883 category: QueueCategory::Checking,
884 is_active: true,
885 is_inactive: false,
886 recently_started: false,
887 seed_rank: None,
888 },
889 ];
890 let config = QueueConfig {
891 active_checking: 1,
892 ..default_config()
893 };
894 let decision = evaluate(&candidates, &config);
895 assert_eq!(decision.to_pause, vec![make_hash(21)]);
896 }
897
898 #[test]
899 fn evaluate_checking_counts_toward_active_limit() {
900 let candidates = vec![
901 QueueCandidate {
902 info_hash: make_hash(20),
903 position: 0,
904 category: QueueCategory::Checking,
905 is_active: true,
906 is_inactive: false,
907 recently_started: false,
908 seed_rank: None,
909 },
910 QueueCandidate {
911 info_hash: make_hash(0),
912 position: 0,
913 category: QueueCategory::Downloading,
914 is_active: true,
915 is_inactive: false,
916 recently_started: false,
917 seed_rank: None,
918 },
919 QueueCandidate {
920 info_hash: make_hash(10),
921 position: 0,
922 category: QueueCategory::Seeding,
923 is_active: true,
924 is_inactive: false,
925 recently_started: false,
926 seed_rank: None,
927 },
928 ];
929 let config = QueueConfig {
931 active_limit: 2,
932 active_checking: 5,
933 ..default_config()
934 };
935 let decision = evaluate(&candidates, &config);
936 assert_eq!(decision.to_pause, vec![make_hash(10)]);
937 }
938
939 #[test]
940 fn evaluate_checking_processed_first() {
941 let candidates = vec![
943 QueueCandidate {
944 info_hash: make_hash(20),
945 position: 0,
946 category: QueueCategory::Checking,
947 is_active: false,
948 is_inactive: false,
949 recently_started: false,
950 seed_rank: None,
951 },
952 QueueCandidate {
953 info_hash: make_hash(0),
954 position: 0,
955 category: QueueCategory::Downloading,
956 is_active: false,
957 is_inactive: false,
958 recently_started: false,
959 seed_rank: None,
960 },
961 QueueCandidate {
962 info_hash: make_hash(1),
963 position: 1,
964 category: QueueCategory::Downloading,
965 is_active: false,
966 is_inactive: false,
967 recently_started: false,
968 seed_rank: None,
969 },
970 ];
971 let config = QueueConfig {
972 active_limit: 2,
973 active_checking: 1,
974 ..default_config()
975 };
976 let decision = evaluate(&candidates, &config);
977 assert!(
978 decision.to_resume.contains(&make_hash(20)),
979 "checking resumed"
980 );
981 assert!(
982 decision.to_resume.contains(&make_hash(0)),
983 "first DL resumed"
984 );
985 assert!(
986 !decision.to_resume.contains(&make_hash(1)),
987 "second DL blocked by active_limit"
988 );
989 }
990
991 #[test]
994 fn evaluate_metadata_uses_checking_budget() {
995 let candidates = vec![
997 QueueCandidate {
998 info_hash: make_hash(20),
999 position: 0,
1000 category: QueueCategory::Checking,
1001 is_active: true,
1002 is_inactive: false,
1003 recently_started: false,
1004 seed_rank: None,
1005 },
1006 QueueCandidate {
1007 info_hash: make_hash(0),
1008 position: 0,
1009 category: QueueCategory::Downloading,
1010 is_active: true,
1011 is_inactive: false,
1012 recently_started: false,
1013 seed_rank: None,
1014 },
1015 QueueCandidate {
1016 info_hash: make_hash(1),
1017 position: 1,
1018 category: QueueCategory::Downloading,
1019 is_active: true,
1020 is_inactive: false,
1021 recently_started: false,
1022 seed_rank: None,
1023 },
1024 QueueCandidate {
1025 info_hash: make_hash(2),
1026 position: 2,
1027 category: QueueCategory::Downloading,
1028 is_active: true,
1029 is_inactive: false,
1030 recently_started: false,
1031 seed_rank: None,
1032 },
1033 ];
1034 let config = QueueConfig {
1035 active_checking: 3,
1036 ..default_config()
1037 };
1038 let decision = evaluate(&candidates, &config);
1039 assert!(
1040 decision.to_pause.is_empty(),
1041 "metadata in Checking doesn't consume DL slot"
1042 );
1043 assert!(decision.to_resume.is_empty());
1044 }
1045
1046 #[test]
1047 fn evaluate_metadata_respects_checking_limit() {
1048 let candidates = vec![
1050 QueueCandidate {
1051 info_hash: make_hash(20),
1052 position: 0,
1053 category: QueueCategory::Checking,
1054 is_active: true,
1055 is_inactive: false,
1056 recently_started: false,
1057 seed_rank: None,
1058 },
1059 QueueCandidate {
1060 info_hash: make_hash(21),
1061 position: 1,
1062 category: QueueCategory::Checking,
1063 is_active: true,
1064 is_inactive: false,
1065 recently_started: false,
1066 seed_rank: None,
1067 },
1068 QueueCandidate {
1069 info_hash: make_hash(22),
1070 position: 2,
1071 category: QueueCategory::Checking,
1072 is_active: true,
1073 is_inactive: false,
1074 recently_started: false,
1075 seed_rank: None,
1076 },
1077 QueueCandidate {
1078 info_hash: make_hash(23),
1079 position: 3,
1080 category: QueueCategory::Checking,
1081 is_active: true,
1082 is_inactive: false,
1083 recently_started: false,
1084 seed_rank: None,
1085 },
1086 ];
1087 let config = QueueConfig {
1088 active_checking: 3,
1089 ..default_config()
1090 };
1091 let decision = evaluate(&candidates, &config);
1092 assert_eq!(decision.to_pause, vec![make_hash(23)]);
1093 }
1094
1095 #[test]
1098 fn evaluate_recently_started_exempt_from_pause() {
1099 let candidates = vec![
1101 QueueCandidate {
1102 info_hash: make_hash(0),
1103 position: 0,
1104 category: QueueCategory::Downloading,
1105 is_active: true,
1106 is_inactive: false,
1107 recently_started: false,
1108 seed_rank: None,
1109 },
1110 QueueCandidate {
1111 info_hash: make_hash(1),
1112 position: 1,
1113 category: QueueCategory::Downloading,
1114 is_active: true,
1115 is_inactive: false,
1116 recently_started: false,
1117 seed_rank: None,
1118 },
1119 QueueCandidate {
1120 info_hash: make_hash(2),
1121 position: 2,
1122 category: QueueCategory::Downloading,
1123 is_active: true,
1124 is_inactive: false,
1125 recently_started: false,
1126 seed_rank: None,
1127 },
1128 QueueCandidate {
1129 info_hash: make_hash(3),
1130 position: 3,
1131 category: QueueCategory::Downloading,
1132 is_active: true,
1133 is_inactive: false,
1134 recently_started: true,
1135 seed_rank: None,
1136 },
1137 ];
1138 let config = default_config();
1139 let decision = evaluate(&candidates, &config);
1140 assert!(
1141 !decision.to_pause.contains(&make_hash(3)),
1142 "recently_started torrent must not be paused"
1143 );
1144 }
1145
1146 #[test]
1147 fn evaluate_recently_started_still_counts_toward_limit() {
1148 let candidates = vec![
1151 QueueCandidate {
1152 info_hash: make_hash(0),
1153 position: 0,
1154 category: QueueCategory::Downloading,
1155 is_active: true,
1156 is_inactive: false,
1157 recently_started: false,
1158 seed_rank: None,
1159 },
1160 QueueCandidate {
1161 info_hash: make_hash(1),
1162 position: 1,
1163 category: QueueCategory::Downloading,
1164 is_active: true,
1165 is_inactive: false,
1166 recently_started: false,
1167 seed_rank: None,
1168 },
1169 QueueCandidate {
1170 info_hash: make_hash(2),
1171 position: 2,
1172 category: QueueCategory::Downloading,
1173 is_active: true,
1174 is_inactive: false,
1175 recently_started: true,
1176 seed_rank: None,
1177 },
1178 QueueCandidate {
1179 info_hash: make_hash(3),
1180 position: 3,
1181 category: QueueCategory::Downloading,
1182 is_active: false,
1183 is_inactive: false,
1184 recently_started: false,
1185 seed_rank: None,
1186 },
1187 ];
1188 let config = default_config();
1189 let decision = evaluate(&candidates, &config);
1190 assert!(
1191 !decision.to_resume.contains(&make_hash(3)),
1192 "queued torrent blocked: recently_started holds the slot"
1193 );
1194 }
1195
1196 #[test]
1197 fn evaluate_not_recently_started_can_be_paused() {
1198 let candidates = vec![
1200 QueueCandidate {
1201 info_hash: make_hash(0),
1202 position: 0,
1203 category: QueueCategory::Downloading,
1204 is_active: true,
1205 is_inactive: false,
1206 recently_started: false,
1207 seed_rank: None,
1208 },
1209 QueueCandidate {
1210 info_hash: make_hash(1),
1211 position: 1,
1212 category: QueueCategory::Downloading,
1213 is_active: true,
1214 is_inactive: false,
1215 recently_started: false,
1216 seed_rank: None,
1217 },
1218 QueueCandidate {
1219 info_hash: make_hash(2),
1220 position: 2,
1221 category: QueueCategory::Downloading,
1222 is_active: true,
1223 is_inactive: false,
1224 recently_started: false,
1225 seed_rank: None,
1226 },
1227 QueueCandidate {
1228 info_hash: make_hash(3),
1229 position: 3,
1230 category: QueueCategory::Downloading,
1231 is_active: true,
1232 is_inactive: false,
1233 recently_started: false,
1234 seed_rank: None,
1235 },
1236 ];
1237 let config = default_config();
1238 let decision = evaluate(&candidates, &config);
1239 assert_eq!(decision.to_pause, vec![make_hash(3)]);
1240 }
1241
1242 #[test]
1245 fn rate_based_inactive_uses_realtime_rate() {
1246 let candidates = vec![QueueCandidate {
1250 info_hash: make_hash(0),
1251 position: 0,
1252 category: QueueCategory::Downloading,
1253 is_active: true,
1254 is_inactive: true,
1257 recently_started: false,
1258 seed_rank: None,
1259 }];
1260 let config = QueueConfig {
1261 active_downloads: 1,
1262 ..default_config()
1263 };
1264 let decision = evaluate(&candidates, &config);
1265 assert!(
1267 decision.to_pause.is_empty(),
1268 "inactive torrent exempt from pausing"
1269 );
1270 }
1271
1272 #[test]
1275 fn evaluate_seed_rank_high_demand_first() {
1276 let candidates = vec![
1278 QueueCandidate {
1279 info_hash: make_hash(10),
1280 position: 0,
1281 category: QueueCategory::Seeding,
1282 is_active: false,
1283 is_inactive: false,
1284 recently_started: false,
1285 seed_rank: Some(100),
1286 },
1287 QueueCandidate {
1288 info_hash: make_hash(11),
1289 position: 1,
1290 category: QueueCategory::Seeding,
1291 is_active: false,
1292 is_inactive: false,
1293 recently_started: false,
1294 seed_rank: Some(5000),
1295 },
1296 QueueCandidate {
1297 info_hash: make_hash(12),
1298 position: 2,
1299 category: QueueCategory::Seeding,
1300 is_active: false,
1301 is_inactive: false,
1302 recently_started: false,
1303 seed_rank: Some(200),
1304 },
1305 ];
1306 let config = QueueConfig {
1307 active_seeds: 2,
1308 ..default_config()
1309 };
1310 let decision = evaluate(&candidates, &config);
1311 assert_eq!(decision.to_resume.len(), 2);
1312 assert_eq!(decision.to_resume[0], make_hash(11), "highest demand first");
1313 assert_eq!(
1314 decision.to_resume[1],
1315 make_hash(12),
1316 "second highest demand"
1317 );
1318 }
1319
1320 #[test]
1321 fn evaluate_seed_rank_tie_breaks_by_position() {
1322 let candidates = vec![
1323 QueueCandidate {
1324 info_hash: make_hash(10),
1325 position: 0,
1326 category: QueueCategory::Seeding,
1327 is_active: false,
1328 is_inactive: false,
1329 recently_started: false,
1330 seed_rank: Some(500),
1331 },
1332 QueueCandidate {
1333 info_hash: make_hash(11),
1334 position: 1,
1335 category: QueueCategory::Seeding,
1336 is_active: false,
1337 is_inactive: false,
1338 recently_started: false,
1339 seed_rank: Some(500),
1340 },
1341 ];
1342 let config = QueueConfig {
1343 active_seeds: 1,
1344 ..default_config()
1345 };
1346 let decision = evaluate(&candidates, &config);
1347 assert_eq!(
1348 decision.to_resume,
1349 vec![make_hash(10)],
1350 "lower position wins on tie"
1351 );
1352 }
1353
1354 #[test]
1355 fn evaluate_seed_rank_none_treated_as_zero() {
1356 let candidates = vec![
1358 QueueCandidate {
1359 info_hash: make_hash(10),
1360 position: 0,
1361 category: QueueCategory::Seeding,
1362 is_active: false,
1363 is_inactive: false,
1364 recently_started: false,
1365 seed_rank: None,
1366 },
1367 QueueCandidate {
1368 info_hash: make_hash(11),
1369 position: 1,
1370 category: QueueCategory::Seeding,
1371 is_active: false,
1372 is_inactive: false,
1373 recently_started: false,
1374 seed_rank: Some(100),
1375 },
1376 ];
1377 let config = QueueConfig {
1378 active_seeds: 1,
1379 ..default_config()
1380 };
1381 let decision = evaluate(&candidates, &config);
1382 assert_eq!(
1383 decision.to_resume,
1384 vec![make_hash(11)],
1385 "known demand beats unknown"
1386 );
1387 }
1388
1389 #[test]
1390 fn compute_seed_rank_zero_seeders_returns_max() {
1391 assert_eq!(compute_seed_rank(0, 5), i32::MAX);
1392 }
1393
1394 #[test]
1395 fn compute_seed_rank_zero_leechers_returns_zero() {
1396 assert_eq!(compute_seed_rank(5, 0), 0);
1397 }
1398
1399 #[test]
1402 fn preemption_displaces_lower_priority() {
1403 let candidates = vec![
1406 QueueCandidate {
1407 info_hash: make_hash(0),
1408 position: 0,
1409 category: QueueCategory::Downloading,
1410 is_active: true,
1411 is_inactive: false,
1412 recently_started: false,
1413 seed_rank: None,
1414 },
1415 QueueCandidate {
1416 info_hash: make_hash(1),
1417 position: 1,
1418 category: QueueCategory::Downloading,
1419 is_active: true,
1420 is_inactive: false,
1421 recently_started: false,
1422 seed_rank: None,
1423 },
1424 QueueCandidate {
1425 info_hash: make_hash(2),
1426 position: 2,
1427 category: QueueCategory::Downloading,
1428 is_active: false,
1429 is_inactive: false,
1430 recently_started: false,
1431 seed_rank: None,
1432 },
1433 QueueCandidate {
1434 info_hash: make_hash(4),
1435 position: 4,
1436 category: QueueCategory::Downloading,
1437 is_active: true,
1438 is_inactive: false,
1439 recently_started: false,
1440 seed_rank: None,
1441 },
1442 ];
1443 let mut decision = QueueDecision::default();
1445 apply_preemption(&mut decision, &candidates);
1446 assert!(
1447 decision.to_pause.contains(&make_hash(4)),
1448 "position 4 paused"
1449 );
1450 assert!(
1451 decision.to_resume.contains(&make_hash(2)),
1452 "position 2 resumed"
1453 );
1454 }
1455
1456 #[test]
1457 fn preemption_respects_recently_started() {
1458 let candidates = vec![
1460 QueueCandidate {
1461 info_hash: make_hash(0),
1462 position: 0,
1463 category: QueueCategory::Downloading,
1464 is_active: false,
1465 is_inactive: false,
1466 recently_started: false,
1467 seed_rank: None,
1468 },
1469 QueueCandidate {
1470 info_hash: make_hash(4),
1471 position: 4,
1472 category: QueueCategory::Downloading,
1473 is_active: true,
1474 is_inactive: false,
1475 recently_started: true,
1476 seed_rank: None,
1477 },
1478 ];
1479 let mut decision = QueueDecision::default();
1480 apply_preemption(&mut decision, &candidates);
1481 assert!(
1482 decision.to_pause.is_empty(),
1483 "recently_started not displaced"
1484 );
1485 assert!(decision.to_resume.is_empty(), "no swap");
1486 }
1487
1488 #[test]
1489 fn preemption_noop_when_optimal() {
1490 let candidates = vec![
1492 QueueCandidate {
1493 info_hash: make_hash(0),
1494 position: 0,
1495 category: QueueCategory::Downloading,
1496 is_active: true,
1497 is_inactive: false,
1498 recently_started: false,
1499 seed_rank: None,
1500 },
1501 QueueCandidate {
1502 info_hash: make_hash(1),
1503 position: 1,
1504 category: QueueCategory::Downloading,
1505 is_active: true,
1506 is_inactive: false,
1507 recently_started: false,
1508 seed_rank: None,
1509 },
1510 QueueCandidate {
1511 info_hash: make_hash(5),
1512 position: 5,
1513 category: QueueCategory::Downloading,
1514 is_active: false,
1515 is_inactive: false,
1516 recently_started: false,
1517 seed_rank: None,
1518 },
1519 ];
1520 let mut decision = QueueDecision::default();
1521 apply_preemption(&mut decision, &candidates);
1522 assert!(decision.to_pause.is_empty(), "no swaps needed");
1523 assert!(decision.to_resume.is_empty(), "no swaps needed");
1524 }
1525
1526 #[test]
1527 fn preemption_single_swap_per_category() {
1528 let candidates = vec![
1531 QueueCandidate {
1532 info_hash: make_hash(0),
1533 position: 0,
1534 category: QueueCategory::Downloading,
1535 is_active: false,
1536 is_inactive: false,
1537 recently_started: false,
1538 seed_rank: None,
1539 },
1540 QueueCandidate {
1541 info_hash: make_hash(1),
1542 position: 1,
1543 category: QueueCategory::Downloading,
1544 is_active: false,
1545 is_inactive: false,
1546 recently_started: false,
1547 seed_rank: None,
1548 },
1549 QueueCandidate {
1550 info_hash: make_hash(3),
1551 position: 3,
1552 category: QueueCategory::Downloading,
1553 is_active: true,
1554 is_inactive: false,
1555 recently_started: false,
1556 seed_rank: None,
1557 },
1558 QueueCandidate {
1559 info_hash: make_hash(4),
1560 position: 4,
1561 category: QueueCategory::Downloading,
1562 is_active: true,
1563 is_inactive: false,
1564 recently_started: false,
1565 seed_rank: None,
1566 },
1567 QueueCandidate {
1568 info_hash: make_hash(5),
1569 position: 5,
1570 category: QueueCategory::Downloading,
1571 is_active: true,
1572 is_inactive: false,
1573 recently_started: false,
1574 seed_rank: None,
1575 },
1576 ];
1577 let mut decision = QueueDecision::default();
1578 apply_preemption(&mut decision, &candidates);
1579 assert_eq!(
1580 decision.to_pause,
1581 vec![make_hash(5)],
1582 "only pos 5 displaced"
1583 );
1584 assert_eq!(
1585 decision.to_resume,
1586 vec![make_hash(0)],
1587 "only pos 0 promoted"
1588 );
1589 }
1590}