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::>(); 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::>(); 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::>(); 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::>(); 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::>(); 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(()) }