Files
aria2-rust-pro/crates/aria2-rust-pro-core/src/engine/tests.rs
T

1528 lines
51 KiB
Rust

use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};
use aria2_rust_pro_storage::{load_session_file, save_session_file};
use crate::{
engine::{DownloadEngine, QueuePositionMode},
error::{CoreError, Result},
piece::{PieceId, PieceState},
request::{
BtFileInfo, BtPeerInfo, BtRuntimeState, BtTrackerInfo, DownloadStatus, RequestGroup,
ResumeState, SegmentState,
},
runtime::RuntimeConfig,
scheduler::ScheduleDecision,
session::SaveSessionTarget,
};
fn temp_session_path(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock should be monotonic enough for test naming")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"aria2-rust-pro-core-test-{}-{nanos}",
std::process::id()
));
fs::create_dir_all(&root).expect("temp dir should be creatable");
root.join(name)
}
#[test]
fn engine_load_session_restores_saved_options() -> Result<()> {
let mut engine = DownloadEngine::new();
let path = PathBuf::from("engine-session.txt");
engine.set_option("max-download-result", "500");
engine.save_session(SaveSessionTarget::Path(path.clone()))?;
engine.set_option("max-download-result", "10");
engine.load_session(SaveSessionTarget::Path(path))?;
assert_eq!(
engine
.session()
.global_options()
.get(&"max-download-result".into())
.and_then(|value| value.as_text()),
Some("500")
);
Ok(())
}
#[test]
fn resume_moves_paused_download_back_to_waiting() {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("https://example.org/resume.bin").gid();
engine.pause(gid).expect("pause should succeed");
engine.resume(gid).expect("resume should succeed");
let group = engine
.registry()
.get(gid)
.expect("group should still exist after resume");
assert_eq!(group.status(), &DownloadStatus::Waiting);
}
#[test]
fn resume_rejects_non_paused_downloads() {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("https://example.org/not-paused.bin").gid();
assert_eq!(
engine.resume(gid),
Err(CoreError::InvalidState("download cannot be unpaused now"))
);
}
#[test]
fn engine_save_session_writes_session_and_control_files() -> Result<()> {
let session_path = temp_session_path("session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut engine = DownloadEngine::with_runtime(runtime);
let gid = engine.add_uri("https://example.org/files/ubuntu.iso").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_option("dir", "D:/downloads");
group.set_option("out", "ubuntu.iso");
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Downloading);
engine.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let session_file = load_session_file(&session_path).expect("session file should load");
assert_eq!(session_file.entries.len(), 1);
let entry = session_file
.entries
.first()
.expect("session file should contain one entry");
assert_eq!(entry.uri, "https://example.org/files/ubuntu.iso");
assert_eq!(
entry.uris,
vec!["https://example.org/files/ubuntu.iso".to_owned()]
);
assert_eq!(
entry.target_path,
PathBuf::from("D:/downloads").join("ubuntu.iso")
);
let control_path = entry
.metadata_path
.clone()
.expect("control metadata path should be present");
assert!(control_path.exists());
let control = aria2_rust_pro_storage::read_aria2_control_file(&control_path)
.expect("control file should load");
assert_eq!(
control
.files
.first()
.expect("control file should contain one file entry")
.path,
PathBuf::from("D:/downloads").join("ubuntu.iso")
);
assert_eq!(control.piece_states.len(), 2);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn engine_save_and_load_session_round_trips_multiple_uris() -> Result<()> {
let session_path = temp_session_path("multi-uri-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer.add_uri("https://example.org/rebuild/file.bin").gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.context_mut().replace_uris(vec![
"https://example.org/rebuild/file.bin".to_owned(),
"https://mirror1.example.org/rebuild/file.bin".to_owned(),
"https://mirror2.example.org/rebuild/file.bin".to_owned(),
]);
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let session_file = load_session_file(&session_path).expect("session file should load");
assert_eq!(
session_file
.entries
.first()
.expect("session file should contain one entry")
.uris,
vec![
"https://example.org/rebuild/file.bin".to_owned(),
"https://mirror1.example.org/rebuild/file.bin".to_owned(),
"https://mirror2.example.org/rebuild/file.bin".to_owned(),
]
);
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
assert_eq!(
loaded.uris(),
&[
"https://example.org/rebuild/file.bin".to_owned(),
"https://mirror1.example.org/rebuild/file.bin".to_owned(),
"https://mirror2.example.org/rebuild/file.bin".to_owned(),
]
);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn engine_load_session_rebuilds_registry_from_saved_file() -> Result<()> {
let session_path = temp_session_path("reload-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer.add_uri("https://example.org/rebuild/file.bin").gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.set_option("dir", "C:/aria2-work");
group.set_option("out", "file.bin");
group.set_status(DownloadStatus::Paused);
group.set_piece_state(PieceId(3), PieceState::Verified);
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
assert_eq!(reader.registry().len(), 1);
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
assert_eq!(loaded.uri(), "https://example.org/rebuild/file.bin");
assert_eq!(loaded.status(), &DownloadStatus::Paused);
assert_eq!(
loaded
.options()
.get(&"out".into())
.and_then(|value| value.as_text()),
Some("file.bin")
);
assert_eq!(loaded.piece_state(PieceId(3)), Some(PieceState::Verified));
assert_eq!(reader.progress_snapshot(loaded_gid)?.completed_length, 1024);
assert_eq!(reader.progress_snapshot(loaded_gid)?.total_length, 4096);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn progress_snapshot_derives_lengths_and_eta_from_runtime_state() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("https://example.org/runtime.bin").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_piece_length(1024);
group.set_total_length(3072);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Downloading);
group.set_completed_length(1536);
group.set_download_speed(512);
group.set_upload_length(128);
group.set_num_connections(3);
group.set_status(DownloadStatus::Active);
let snapshot = engine.progress_snapshot(gid)?;
assert_eq!(snapshot.total_length, 3072);
assert_eq!(snapshot.completed_length, 1536);
assert_eq!(snapshot.download_speed, 512);
assert_eq!(snapshot.upload_length, 128);
assert_eq!(snapshot.num_connections, 3);
assert_eq!(snapshot.eta_seconds, Some(3));
Ok(())
}
#[test]
fn global_stat_sums_snapshot_lengths_and_speeds() {
let mut engine = DownloadEngine::new();
let gid1 = engine.add_uri("https://example.org/a.bin").gid();
let gid2 = engine.add_uri("https://example.org/b.bin").gid();
let group1 = engine.handle_mut(gid1).expect("group 1 should exist");
group1.set_piece_length(1024);
group1.set_total_length(2048);
group1.set_piece_state(PieceId(0), PieceState::Verified);
group1.set_completed_length(1536);
group1.set_download_speed(100);
group1.set_status(DownloadStatus::Active);
let group2 = engine.handle_mut(gid2).expect("group 2 should exist");
group2.set_piece_length(1024);
group2.set_total_length(1024);
group2.set_piece_state(PieceId(0), PieceState::Verified);
group2.set_completed_length(1024);
group2.set_download_speed(50);
group2.set_upload_length(25);
group2.set_upload_speed(25);
group2.set_status(DownloadStatus::Paused);
let stat = engine.get_global_stat();
assert_eq!(stat.total_length, 3072);
assert_eq!(stat.completed_length, 2560);
assert_eq!(stat.download_speed, 150);
assert_eq!(stat.upload_speed, 25);
}
#[test]
fn tell_waiting_includes_paused_and_tell_stopped_excludes_paused() {
let mut engine = DownloadEngine::new();
let waiting_gid = engine.add_uri("https://example.org/waiting.bin").gid();
let paused_gid = engine.add_uri("https://example.org/paused.bin").gid();
let complete_gid = engine.add_uri("https://example.org/complete.bin").gid();
engine.pause(paused_gid).expect("pause should succeed");
engine
.complete(complete_gid)
.expect("complete should succeed");
let waiting = engine
.tell_waiting()
.into_iter()
.map(super::DownloadHandle::gid)
.collect::<Vec<_>>();
assert_eq!(waiting.len(), 2);
assert!(waiting.contains(&waiting_gid));
assert!(waiting.contains(&paused_gid));
let stopped = engine
.tell_stopped()
.into_iter()
.map(super::DownloadHandle::gid)
.collect::<Vec<_>>();
assert_eq!(stopped, vec![complete_gid]);
}
#[test]
fn change_position_reorders_waiting_queue_with_set_cur_and_end_modes() {
let mut engine = DownloadEngine::new();
let gid0 = engine.add_uri("https://example.org/0.bin").gid();
let gid1 = engine.add_uri("https://example.org/1.bin").gid();
let gid2 = engine.add_uri("https://example.org/2.bin").gid();
let gid3 = engine.add_uri("https://example.org/3.bin").gid();
let gid4 = engine.add_uri("https://example.org/4.bin").gid();
assert_eq!(
engine
.change_position(gid1, 4, QueuePositionMode::Set)
.expect("set move should succeed"),
4
);
assert_eq!(
engine
.change_position(gid2, 3, QueuePositionMode::Set)
.expect("set move should succeed"),
3
);
assert_eq!(
engine
.change_position(gid2, 1, QueuePositionMode::Set)
.expect("set move should succeed"),
1
);
assert_eq!(
engine
.change_position(gid1, 1, QueuePositionMode::Cur)
.expect("cur move should succeed"),
4
);
assert_eq!(
engine
.change_position(gid0, -2, QueuePositionMode::End)
.expect("end move should succeed"),
2
);
let waiting = engine
.tell_waiting()
.into_iter()
.map(super::DownloadHandle::gid)
.collect::<Vec<_>>();
assert_eq!(waiting, vec![gid2, gid3, gid0, gid4, gid1]);
}
#[test]
fn pause_active_download_moves_it_to_front_of_waiting_queue() {
let mut engine = DownloadEngine::new();
let gid0 = engine.add_uri("https://example.org/0.bin").gid();
let gid1 = engine.add_uri("https://example.org/1.bin").gid();
let _ = engine.schedule_once();
engine.pause(gid0).expect("pause should succeed");
let waiting = engine
.tell_waiting()
.into_iter()
.map(super::DownloadHandle::gid)
.collect::<Vec<_>>();
assert_eq!(waiting, vec![gid0, gid1]);
}
#[test]
fn pause_rejects_already_paused_downloads() {
let mut engine = DownloadEngine::new();
let gid = engine
.add_uri("https://example.org/already-paused.bin")
.gid();
engine.pause(gid).expect("initial pause should succeed");
assert_eq!(
engine.pause(gid),
Err(CoreError::InvalidState("download cannot be paused now"))
);
}
#[test]
fn remove_download_result_only_removes_stopped_entries() {
let mut engine = DownloadEngine::new();
let waiting_gid = engine.add_uri("https://example.org/waiting.bin").gid();
let complete_gid = engine.add_uri("https://example.org/complete.bin").gid();
let error_gid = engine.add_uri("https://example.org/error.bin").gid();
engine
.complete(complete_gid)
.expect("complete transition should succeed");
engine
.fail(error_gid)
.expect("error transition should succeed");
engine
.remove_download_result(complete_gid)
.expect("stopped result should be removable");
assert!(engine.registry().get(complete_gid).is_none());
assert!(engine.registry().get(error_gid).is_some());
assert!(engine.registry().get(waiting_gid).is_some());
assert_eq!(
engine.remove_download_result(waiting_gid),
Err(CoreError::InvalidState(
"download result is not available for active or waiting downloads",
))
);
}
#[test]
fn purge_download_results_removes_only_stopped_entries() {
let mut engine = DownloadEngine::new();
let waiting_gid = engine.add_uri("https://example.org/waiting.bin").gid();
let paused_gid = engine.add_uri("https://example.org/paused.bin").gid();
let complete_gid = engine.add_uri("https://example.org/complete.bin").gid();
let removed_gid = engine.add_uri("https://example.org/removed.bin").gid();
let error_gid = engine.add_uri("https://example.org/error.bin").gid();
engine.pause(paused_gid).expect("pause should succeed");
engine
.complete(complete_gid)
.expect("complete transition should succeed");
engine
.remove(removed_gid)
.expect("remove transition should succeed");
engine
.fail(error_gid)
.expect("error transition should succeed");
assert_eq!(engine.purge_download_results(), 3);
assert!(engine.registry().get(waiting_gid).is_some());
assert!(engine.registry().get(paused_gid).is_some());
assert!(engine.registry().get(complete_gid).is_none());
assert!(engine.registry().get(removed_gid).is_none());
assert!(engine.registry().get(error_gid).is_none());
}
#[test]
fn progress_snapshot_prefers_verified_piece_progress_when_larger() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine
.add_uri("https://example.org/verified-dominates.bin")
.gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_piece_length(1024);
group.set_total_length(4096);
group.set_completed_length(512);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Verified);
let snapshot = engine.progress_snapshot(gid)?;
assert_eq!(snapshot.completed_length, 2048);
Ok(())
}
#[test]
fn save_and_load_session_round_trips_retry_and_completed_metrics() -> Result<()> {
let session_path = temp_session_path("metrics-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer.add_uri("https://example.org/metrics.bin").gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.set_piece_length(1024);
group.set_total_length(4096);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_completed_length(3072);
group.increment_retry_count();
group.increment_retry_count();
group.set_status(DownloadStatus::Active);
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
assert_eq!(loaded.retry_count(), 2);
assert_eq!(loaded.completed_length(), 3072);
assert_eq!(reader.progress_snapshot(loaded_gid)?.completed_length, 3072);
assert_eq!(loaded.status(), &DownloadStatus::Active);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn save_and_load_session_round_trips_bt_selected_file_state() -> Result<()> {
let session_path = temp_session_path("bt-selected-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer.add_uri("magnet:?xt=urn:btih:abcdef").gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.set_bt(BtRuntimeState {
info_hash: "abcdef".to_owned(),
name: Some("linux-iso-pack".to_owned()),
metadata_only: false,
files: vec![
BtFileInfo {
path: "disc1.iso".to_owned(),
length: 1024,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "disc2.iso".to_owned(),
length: 2048,
piece_offset: Some(1024),
selected: false,
},
],
..BtRuntimeState::default()
});
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
let bt = loaded.bt().expect("bt runtime state should round-trip");
assert_eq!(bt.info_hash, "abcdef");
assert_eq!(bt.files.len(), 2);
assert!(
bt.files
.first()
.expect("bt file list should contain the first file")
.selected
);
assert!(
!bt.files
.get(1)
.expect("bt file list should contain the second file")
.selected
);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn save_and_load_session_round_trips_paused_bt_group_state() -> Result<()> {
let session_path = temp_session_path("bt-paused-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer.add_uri("magnet:?xt=urn:btih:123456").gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Paused);
group.set_bt(BtRuntimeState {
info_hash: "123456".to_owned(),
metadata_only: true,
files: vec![BtFileInfo {
path: "metadata.part".to_owned(),
length: 512,
piece_offset: None,
selected: true,
}],
..BtRuntimeState::default()
});
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
assert_eq!(loaded.status(), &DownloadStatus::Paused);
let bt = loaded.bt().expect("bt runtime state should round-trip");
assert_eq!(bt.info_hash, "123456");
assert_eq!(bt.files.len(), 1);
let first_file = bt
.files
.first()
.expect("bt file list should contain the metadata placeholder");
assert_eq!(first_file.path, "metadata.part");
assert!(first_file.selected);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn load_session_recovers_partial_progress_from_control_file_when_session_metadata_lacks_it()
-> Result<()> {
let session_path = temp_session_path("control-resume-session.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer
.add_uri("https://example.org/control-resume.bin")
.gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.set_piece_length(1024);
group.set_total_length(4096);
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Downloading);
group.set_completed_length(1536);
group.increment_retry_count();
group.increment_retry_count();
group.set_status(DownloadStatus::Active);
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let mut session_file = load_session_file(&session_path)
.map_err(|_| CoreError::StorageUnavailable("failed to read session file"))?;
let entry = session_file
.entries
.first_mut()
.expect("saved session should contain one entry");
let metadata = entry
.metadata
.as_mut()
.expect("saved session entry should contain metadata");
metadata.remove("completed_length");
metadata.remove("retry_count");
save_session_file(&session_path, &session_file)
.map_err(|_| CoreError::StorageUnavailable("failed to write session file"))?;
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
let snapshot = reader.progress_snapshot(loaded_gid)?;
assert_eq!(loaded.status(), &DownloadStatus::Active);
assert_eq!(loaded.piece_state(PieceId(0)), Some(PieceState::Verified));
assert_eq!(
loaded.piece_state(PieceId(1)),
Some(PieceState::Downloading)
);
assert_eq!(loaded.completed_length(), 1536);
assert_eq!(loaded.retry_count(), 2);
assert_eq!(snapshot.total_length, 4096);
assert_eq!(snapshot.completed_length, 1536);
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn schedule_once_sets_active_segments_and_runtime_bridge_state() {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("https://example.org/split.bin").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_total_length(8 * 1024);
group.set_completed_length(2 * 1024);
group.set_piece_length(1024);
group.set_status(DownloadStatus::Waiting);
let decision = engine.schedule_once();
assert_eq!(decision, ScheduleDecision::Queue(gid));
let group = engine.handle_mut(gid).expect("group should exist");
assert_eq!(group.status(), &DownloadStatus::Active);
assert_eq!(group.num_connections(), 1);
assert_eq!(group.segment_assignments().len(), 1);
assert_eq!(
group
.segment_assignments()
.first()
.expect("one segment assignment should exist")
.range,
crate::piece::PieceRange::new(2 * 1024, 8 * 1024)
);
let bridge = engine.session().bridge();
assert!(bridge.segment_plan.is_some());
assert_eq!(bridge.completed_length, 2048);
assert_eq!(bridge.retry_count, 0);
assert_eq!(bridge.active_segments, 1);
}
#[test]
fn schedule_once_propagates_error_as_retry_with_backpressure() {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("https://example.org/error.bin").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_total_length(4096);
group.set_completed_length(1024);
group.set_status(DownloadStatus::Error);
let decision = engine.schedule_once();
assert_eq!(decision, ScheduleDecision::RetryLater(gid));
let group = engine.handle_mut(gid).expect("group should exist");
assert_eq!(group.status(), &DownloadStatus::Waiting);
assert_eq!(group.num_connections(), 0);
assert!(group.segment_assignments().is_empty());
assert_eq!(group.retry_count(), 1);
assert_eq!(group.retry_attempts().len(), 1);
assert_eq!(
group
.retry_attempts()
.first()
.expect("one retry attempt should exist")
.error
.as_deref(),
Some("schedule-retry:error-state")
);
let bridge = engine.session().bridge();
assert!(bridge.segment_plan.is_some());
assert_eq!(bridge.retry_count, 1);
assert_eq!(bridge.retry_history.len(), 1);
assert_eq!(bridge.completed_length, 1024);
assert_eq!(bridge.active_segments, 0);
}
#[test]
fn tell_stopped_orders_downloads_by_stop_sequence() {
let mut engine = DownloadEngine::new();
let gid_a = engine.add_uri("https://example.org/a.bin").gid();
let gid_b = engine.add_uri("https://example.org/b.bin").gid();
let gid_c = engine.add_uri("https://example.org/c.bin").gid();
let gid_d = engine.add_uri("https://example.org/d.bin").gid();
engine
.complete(gid_c)
.expect("complete transition should succeed");
engine
.remove(gid_a)
.expect("remove transition should succeed");
engine.fail(gid_d).expect("error transition should succeed");
engine
.complete(gid_b)
.expect("complete transition should succeed");
let gids = engine
.tell_stopped()
.into_iter()
.map(super::DownloadHandle::gid)
.collect::<Vec<_>>();
assert_eq!(gids, vec![gid_c, gid_a, gid_d, gid_b]);
}
#[test]
fn schedule_once_materializes_multiple_piece_aligned_segments() {
let mut engine = DownloadEngine::with_runtime(RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
});
let gid = engine
.add_uri("https://example.org/multi-segment.bin")
.gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_total_length(10 * 1024);
group.set_completed_length(2 * 1024);
group.set_piece_length(1024);
group.set_status(DownloadStatus::Waiting);
let decision = engine.schedule_once();
assert_eq!(decision, ScheduleDecision::Queue(gid));
let group = engine.handle_mut(gid).expect("group should exist");
let assignments = group.segment_assignments();
assert_eq!(assignments.len(), 4);
let first = assignments
.first()
.expect("first segment assignment should exist");
let second = assignments
.get(1)
.expect("second segment assignment should exist");
let third = assignments
.get(2)
.expect("third segment assignment should exist");
let fourth = assignments
.get(3)
.expect("fourth segment assignment should exist");
assert_eq!(first.range, crate::piece::PieceRange::new(2048, 4096));
assert_eq!(second.range, crate::piece::PieceRange::new(4096, 6144));
assert_eq!(third.range, crate::piece::PieceRange::new(6144, 8192));
assert_eq!(fourth.range, crate::piece::PieceRange::new(8192, 10240));
assert!(
assignments
.iter()
.all(|segment| segment.state == SegmentState::Active)
);
}
#[test]
fn schedule_once_starts_segments_from_resume_offset_when_ahead_of_completed_length() {
let mut engine = DownloadEngine::with_runtime(RuntimeConfig {
split: 3,
max_connections_per_server: 3,
max_connection_per_server: 3,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
});
let gid = engine
.add_uri("https://example.org/resume-segment.bin")
.gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_total_length(6 * 1024);
group.set_completed_length(1024);
group.set_piece_length(1024);
group.set_resume_state(ResumeState {
persisted: true,
resume_offset: 3 * 1024,
validated_length: Some(1024),
segment_cursor: Some(PieceId(3)),
});
group.set_status(DownloadStatus::Waiting);
let decision = engine.schedule_once();
assert_eq!(decision, ScheduleDecision::Queue(gid));
let group = engine.handle_mut(gid).expect("group should exist");
let assignments = group.segment_assignments();
assert_eq!(assignments.len(), 3);
assert_eq!(
assignments
.first()
.expect("first resumed segment assignment should exist")
.range
.start,
3 * 1024
);
assert_eq!(
assignments
.get(2)
.expect("third resumed segment assignment should exist")
.range
.end,
6 * 1024
);
}
#[test]
fn progress_snapshot_does_not_force_bt_complete_without_selected_payload() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("magnet:?xt=urn:btih:falsecomplete").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Complete);
group.set_piece_length(1024);
group.set_total_length(4096);
group.set_completed_length(0);
group.set_bt(BtRuntimeState {
info_hash: "falsecomplete".to_owned(),
metadata_only: true,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4096,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
let snapshot = engine.progress_snapshot(gid)?;
assert_eq!(snapshot.total_length, 4096);
assert_eq!(snapshot.completed_length, 0);
assert!(!snapshot.seeding);
assert!(!snapshot.bt_true_seeding);
assert_eq!(snapshot.share_ratio_milli, None);
Ok(())
}
#[test]
fn bt_share_ratio_round_trip_and_pause_resume_state_survive_session_load() -> Result<()> {
let session_path = temp_session_path("bt-share-roundtrip.txt");
let runtime = RuntimeConfig::default().with_session_path(session_path.clone());
let mut writer = DownloadEngine::with_runtime(runtime.clone());
let gid = writer.add_uri("magnet:?xt=urn:btih:sharetest").gid();
let group = writer
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Paused);
group.set_total_length(5000);
group.set_completed_length(3000);
group.set_upload_length(1500);
group.set_bt(BtRuntimeState {
info_hash: "sharetest".to_owned(),
metadata_only: false,
files: vec![
BtFileInfo {
path: "a.bin".to_owned(),
length: 2000,
piece_offset: Some(0),
selected: true,
},
BtFileInfo {
path: "b.bin".to_owned(),
length: 3000,
piece_offset: Some(2000),
selected: false,
},
],
..BtRuntimeState::default()
});
writer.save_session(SaveSessionTarget::Path(session_path.clone()))?;
let mut reader = DownloadEngine::with_runtime(runtime);
reader.load_session(SaveSessionTarget::Path(session_path.clone()))?;
let loaded_gid = reader
.registry()
.handles()
.next()
.expect("loaded registry should contain one gid")
.gid();
let loaded = reader
.registry()
.get(loaded_gid)
.expect("loaded group should exist");
assert_eq!(loaded.status(), &DownloadStatus::Paused);
let snapshot = reader.progress_snapshot(loaded_gid)?;
assert_eq!(snapshot.share_ratio_milli, Some(500));
let root = session_path
.parent()
.expect("session path should have a parent")
.to_path_buf();
let _ = fs::remove_dir_all(root);
Ok(())
}
#[test]
fn apply_bt_peer_snapshot_replaces_runtime_peer_view() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("magnet:?xt=urn:btih:peer-snapshot").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_num_connections(7);
group.set_bt(BtRuntimeState {
info_hash: "peer-snapshot".to_owned(),
metadata_only: false,
..BtRuntimeState::default()
});
engine.apply_bt_peer_snapshot(
gid,
vec![
BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("client-a".to_owned()),
interested: true,
choked: false,
download_speed: 111,
upload_speed: 222,
seeder: false,
},
BtPeerInfo {
peer_id: Some("peer-b".to_owned()),
ip: "127.0.0.2".to_owned(),
port: 6882,
client_name: Some("client-b".to_owned()),
interested: false,
choked: true,
download_speed: 0,
upload_speed: 64,
seeder: true,
},
],
)?;
let saved = engine
.registry()
.get(gid)
.and_then(RequestGroup::bt)
.expect("bt runtime state should exist");
let snapshot = engine.progress_snapshot(gid)?;
assert_eq!(saved.peers.len(), 2);
assert_eq!(
saved
.peers
.first()
.expect("first saved peer should exist")
.ip,
"127.0.0.1"
);
assert!(
saved
.peers
.get(1)
.expect("second saved peer should exist")
.seeder
);
assert_eq!(snapshot.num_connections, 2);
Ok(())
}
#[test]
fn apply_bt_tracker_snapshot_updates_existing_tracker_and_can_append_new_one() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("magnet:?xt=urn:btih:tracker-snapshot").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_bt(BtRuntimeState {
info_hash: "tracker-snapshot".to_owned(),
metadata_only: false,
trackers: vec![BtTrackerInfo {
url: "http://tracker.example.org/announce".to_owned(),
tier: Some(0),
id: None,
seeders: None,
leechers: None,
}],
..BtRuntimeState::default()
});
engine.apply_bt_tracker_snapshot(
gid,
"http://tracker.example.org/announce",
Some("session-a".to_owned()),
Some(12),
Some(4),
)?;
engine.apply_bt_tracker_snapshot(
gid,
"udp://tracker.example.org:6969/announce",
Some("session-b".to_owned()),
Some(18),
Some(6),
)?;
let saved = engine
.registry()
.get(gid)
.and_then(RequestGroup::bt)
.expect("bt runtime state should exist");
assert_eq!(saved.trackers.len(), 2);
let first_tracker = saved
.trackers
.first()
.expect("first saved tracker should exist");
let second_tracker = saved
.trackers
.get(1)
.expect("second saved tracker should exist");
assert_eq!(first_tracker.id.as_deref(), Some("session-a"));
assert_eq!(first_tracker.seeders, Some(12));
assert_eq!(first_tracker.leechers, Some(4));
assert_eq!(
second_tracker.url,
"udp://tracker.example.org:6969/announce"
);
assert_eq!(second_tracker.id.as_deref(), Some("session-b"));
Ok(())
}
#[test]
fn record_bt_runtime_tick_updates_share_time_upload_and_completed_lengths() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("magnet:?xt=urn:btih:bt-runtime-tick").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Active);
group.set_total_length(4096);
group.set_completed_length(1024);
group.set_bt(BtRuntimeState {
info_hash: "bt-runtime-tick".to_owned(),
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4096,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
engine.record_bt_runtime_tick(gid, 2048, 1536, 30, 12, true)?;
let loaded = engine
.registry()
.get(gid)
.expect("group should still exist");
assert_eq!(loaded.completed_length(), 3072);
assert_eq!(loaded.upload_length(), 1536);
assert_eq!(loaded.upload_speed(), 0);
assert!(loaded.bt_is_seeding());
assert_eq!(loaded.bt_share_time_secs(), Some(30));
assert_eq!(loaded.bt_seeding_time_secs(), Some(12));
let snapshot = engine.progress_snapshot(gid)?;
assert_eq!(snapshot.completed_length, 3072);
assert_eq!(snapshot.upload_length, 1536);
assert_eq!(snapshot.share_ratio_milli, Some(375));
assert_eq!(snapshot.share_time_secs, Some(30));
assert_eq!(snapshot.seeding_time_secs, Some(12));
assert!(!snapshot.bt_true_seeding);
Ok(())
}
#[test]
fn bt_update_helpers_feed_progress_snapshot_runtime_metrics() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine
.add_uri("magnet:?xt=urn:btih:bt-progress-metrics")
.gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Active);
group.set_total_length(4 * 1024);
group.set_piece_length(1024);
group.set_bt(BtRuntimeState {
info_hash: "bt-progress-metrics".to_owned(),
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 4 * 1024,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
group.set_piece_state(PieceId(0), PieceState::Pending);
group.set_piece_state(PieceId(1), PieceState::Missing);
group.set_piece_state(PieceId(2), PieceState::Queued);
let piece = engine.apply_bt_piece_block_update(
gid,
crate::request::BtPieceBlockUpdate {
piece_id: PieceId(0),
completed_blocks: 4,
total_blocks: 4,
},
)?;
assert!(piece.transitioned_to_verified);
assert_eq!(piece.completed_length_delta, 1024);
let availability = engine.apply_bt_piece_availability_update(
gid,
crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(2),
peers_with_piece: 6,
},
)?;
assert_eq!(availability.available_piece_count, 1);
let peer = engine.apply_bt_peer_update(
gid,
BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "127.0.0.1".to_owned(),
port: 6881,
client_name: Some("client-a".to_owned()),
interested: true,
choked: false,
download_speed: 700,
upload_speed: 350,
seeder: false,
},
)?;
assert_eq!(peer.peer_count, 1);
assert_eq!(peer.total_download_speed, 700);
assert_eq!(peer.total_upload_speed, 350);
engine.apply_bt_piece_block_update(
gid,
crate::request::BtPieceBlockUpdate {
piece_id: PieceId(1),
completed_blocks: 1,
total_blocks: 4,
},
)?;
let snapshot = engine.progress_snapshot(gid)?;
assert_eq!(snapshot.completed_length, 1024);
assert_eq!(snapshot.remaining_length(), 3 * 1024);
assert_eq!(snapshot.num_connections, 1);
assert_eq!(snapshot.download_speed, 700);
assert_eq!(snapshot.upload_speed, 350);
assert_eq!(snapshot.bt_total_peers, 1);
assert_eq!(snapshot.bt_seeders, 0);
assert_eq!(snapshot.bt_leechers, 1);
assert_eq!(snapshot.bt_available_pieces, 1);
assert_eq!(snapshot.bt_verified_pieces, 1);
assert_eq!(snapshot.bt_downloading_pieces, 1);
assert_eq!(snapshot.bt_queued_pieces, 1);
assert_eq!(snapshot.bt_missing_pieces, 0);
Ok(())
}
#[test]
fn bt_runtime_helpers_drive_true_seeding_and_share_runtime_snapshot() -> Result<()> {
let mut engine = DownloadEngine::new();
let gid = engine.add_uri("magnet:?xt=urn:btih:bt-live-share").gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Active);
group.set_total_length(2_048);
group.set_completed_length(2_048);
group.set_bt(BtRuntimeState {
info_hash: "bt-live-share".to_owned(),
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 2_048,
piece_offset: Some(0),
selected: true,
}],
..BtRuntimeState::default()
});
let started = engine.set_bt_seeding_state(gid, true, Some(1_000))?;
assert!(started.seeding);
assert_eq!(started.share_ratio_milli, Some(0));
let advanced = engine.tick_bt_runtime_clock(gid, 1_040, true)?;
assert!(advanced.seeding);
assert_eq!(advanced.share_time_secs, 40);
assert_eq!(advanced.seeding_time_secs, 40);
let tick = engine.apply_bt_runtime_tick(gid, 0, 1_024, 90, 180, 5, 5, true, Some(16))?;
assert!(tick.seeding);
assert_eq!(tick.upload_length, 1_024);
assert_eq!(tick.download_speed, 90);
assert_eq!(tick.upload_speed, 180);
assert_eq!(tick.num_connections, 16);
assert_eq!(tick.share_ratio_milli, Some(500));
assert_eq!(tick.share_time_secs, 45);
assert_eq!(tick.seeding_time_secs, 45);
let snapshot = engine.progress_snapshot(gid)?;
assert!(snapshot.seeding);
assert!(snapshot.bt_true_seeding);
assert_eq!(snapshot.share_ratio_milli, Some(500));
assert_eq!(snapshot.share_time_secs, Some(45));
assert_eq!(snapshot.seeding_time_secs, Some(45));
assert_eq!(snapshot.bt_selected_payload_length, 2_048);
assert_eq!(snapshot.bt_remaining_payload_length, 0);
assert_eq!(snapshot.upload_speed, 180);
assert_eq!(snapshot.num_connections, 16);
Ok(())
}
#[test]
fn download_runtime_snapshot_exposes_segment_and_bt_pressure_metrics() -> Result<()> {
let mut engine = DownloadEngine::with_runtime(RuntimeConfig {
split: 4,
max_connections_per_server: 4,
max_connection_per_server: 4,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
});
let gid = engine
.add_uri("magnet:?xt=urn:btih:download-runtime-snapshot")
.gid();
let group = engine
.handle_mut(gid)
.expect("newly added group should exist");
group.set_status(DownloadStatus::Active);
group.set_total_length(5 * 1024);
group.set_completed_length(1024);
group.set_piece_length(1024);
group.set_download_speed(900);
group.set_upload_speed(120);
group.set_bt(BtRuntimeState {
info_hash: "download-runtime-snapshot".to_owned(),
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 5 * 1024,
piece_offset: Some(0),
selected: true,
}],
peers: vec![BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "203.0.113.10".to_owned(),
port: 6881,
client_name: None,
interested: true,
choked: false,
download_speed: 900,
upload_speed: 120,
seeder: false,
}],
..BtRuntimeState::default()
});
group.set_piece_state(PieceId(0), PieceState::Verified);
group.set_piece_state(PieceId(1), PieceState::Pending);
group.set_piece_state(PieceId(2), PieceState::Queued);
group.set_piece_state(PieceId(3), PieceState::Downloading);
group.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(1),
peers_with_piece: 1,
});
let decision = engine.schedule_once();
assert_eq!(decision, ScheduleDecision::RunNow(gid));
let snapshot = engine.download_runtime_snapshot(gid)?;
assert_eq!(snapshot.gid, gid);
assert_eq!(snapshot.effective_download_limit, None);
assert_eq!(snapshot.effective_upload_limit, None);
assert_eq!(snapshot.segment_stats.segment_count, 4);
assert_eq!(snapshot.segment_stats.remaining_bytes, 4 * 1024);
assert_eq!(
snapshot
.bt_pressure
.as_ref()
.map(|pressure| pressure.requestable_pieces),
Some(2)
);
assert_eq!(
snapshot
.bt_pressure
.as_ref()
.map(|pressure| pressure.scarce_requestable_pieces),
Some(1)
);
Ok(())
}
#[test]
fn runtime_instrumentation_snapshot_aggregates_scheduler_and_resource_state() {
let mut engine = DownloadEngine::with_runtime(RuntimeConfig {
split: 3,
max_connections_per_server: 3,
max_connection_per_server: 3,
min_split_size: 1024,
piece_length: 1024,
..RuntimeConfig::default()
});
let active_gid = engine.add_uri("https://example.org/a.bin").gid();
let waiting_gid = engine.add_uri("magnet:?xt=urn:btih:runtime-global").gid();
let active = engine
.handle_mut(active_gid)
.expect("active group should exist");
active.set_status(DownloadStatus::Active);
active.set_total_length(4 * 1024);
active.set_completed_length(1024);
active.set_piece_length(1024);
let waiting = engine
.handle_mut(waiting_gid)
.expect("waiting group should exist");
waiting.set_status(DownloadStatus::Waiting);
waiting.set_total_length(3 * 1024);
waiting.set_completed_length(0);
waiting.set_piece_length(1024);
waiting.set_bt(BtRuntimeState {
info_hash: "runtime-global".to_owned(),
metadata_only: false,
files: vec![BtFileInfo {
path: "payload.bin".to_owned(),
length: 3 * 1024,
piece_offset: Some(0),
selected: true,
}],
peers: vec![BtPeerInfo {
peer_id: Some("peer-a".to_owned()),
ip: "203.0.113.20".to_owned(),
port: 6881,
client_name: None,
interested: true,
choked: false,
download_speed: 100,
upload_speed: 20,
seeder: false,
}],
..BtRuntimeState::default()
});
waiting.set_piece_state(PieceId(0), PieceState::Pending);
waiting.set_piece_state(PieceId(1), PieceState::Downloading);
waiting.set_piece_state(PieceId(2), PieceState::Missing);
waiting.apply_bt_piece_availability_update(crate::request::BtPieceAvailabilityUpdate {
piece_id: PieceId(0),
peers_with_piece: 1,
});
let _ = engine.schedule_once();
let runtime = engine.runtime_instrumentation_snapshot();
assert_eq!(runtime.download_count, 2);
assert_eq!(runtime.active_download_count, 1);
assert_eq!(runtime.waiting_download_count, 1);
assert_eq!(runtime.total_active_segments, 3);
assert_eq!(runtime.total_requestable_pieces, 2);
assert_eq!(runtime.total_scarce_requestable_pieces, 1);
assert_eq!(runtime.configured_disk_cache_bytes, 16 * 1024 * 1024);
assert_eq!(runtime.scheduler_counters.schedule_run_count, 1);
assert!(runtime.last_scheduler_plan.is_some());
}
#[test]
fn progress_and_global_stat_apply_speed_limits_from_runtime_and_group_options() -> Result<()> {
let mut engine = DownloadEngine::with_runtime(RuntimeConfig {
max_overall_download_limit: Some(1_200),
max_download_limit: Some(900),
max_overall_upload_limit: Some(600),
max_upload_limit: Some(500),
..RuntimeConfig::default()
});
let gid_a = engine.add_uri("https://example.org/a.bin").gid();
let gid_b = engine.add_uri("https://example.org/b.bin").gid();
let group_a = engine.handle_mut(gid_a).expect("group a should exist");
group_a.set_status(DownloadStatus::Active);
group_a.set_download_speed(2_000);
group_a.set_upload_speed(900);
group_a.set_option("max-download-limit", "700");
group_a.set_option("max-upload-limit", "200");
let group_b = engine.handle_mut(gid_b).expect("group b should exist");
group_b.set_status(DownloadStatus::Active);
group_b.set_download_speed(2_000);
group_b.set_upload_speed(900);
let snapshot_a = engine.progress_snapshot(gid_a)?;
let snapshot_b = engine.progress_snapshot(gid_b)?;
assert_eq!(snapshot_a.download_speed, 600);
assert_eq!(snapshot_a.upload_speed, 200);
assert_eq!(snapshot_b.download_speed, 600);
assert_eq!(snapshot_b.upload_speed, 300);
let runtime_a = engine.download_runtime_snapshot(gid_a)?;
assert_eq!(runtime_a.effective_download_limit, Some(600));
assert_eq!(runtime_a.effective_upload_limit, Some(200));
assert_eq!(runtime_a.download_speed, 600);
assert_eq!(runtime_a.upload_speed, 200);
let stat = engine.get_global_stat();
assert_eq!(stat.download_speed, 1_200);
assert_eq!(stat.upload_speed, 500);
Ok(())
}