use super::*; #[test] fn add_torrent_registers_runtime_backed_bt_metadata_surfaces() { let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), params: vec![RpcValue::String(torrent_payload.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addTorrent result: {other:?}"), }; let status = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2TellStatus.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); match status.result { Some(RpcValue::Object(payload)) => { assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(false))); assert!(matches!( payload.get("announceList"), Some(RpcValue::Array(tiers)) if !tiers.is_empty() )); assert!(matches!( payload.get("magnetUri"), Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:") )); } other => panic!("unexpected tellStatus after addTorrent: {other:?}"), } let files = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetFiles.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); match files.result { Some(RpcValue::Array(entries)) => match entries.first() { Some(RpcValue::Object(file)) => { assert_eq!( file.get("path"), Some(&RpcValue::String("ubuntu.iso".to_owned())) ); assert_eq!( file.get("length"), Some(&RpcValue::String("32768".to_owned())) ); } other => panic!("unexpected getFiles entry after addTorrent: {other:?}"), }, other => panic!("unexpected getFiles result after addTorrent: {other:?}"), } let servers = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetServers.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); let error = servers .error .expect("getServers should reject non-active BT downloads"); assert!( error .message .contains(&format!("No active download for GID#{gid}")) ); } #[test] fn add_uri_magnet_registers_bt_runtime_status_surfaces() { let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-magnet.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddUri.as_str().to_owned(), params: vec![RpcValue::String(magnet.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addUri magnet result: {other:?}"), }; let status = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2TellStatus.as_str().to_owned(), params: vec![RpcValue::String(gid)], meta: Default::default(), }); match status.result { Some(RpcValue::Object(payload)) => { assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true))); assert!(matches!( payload.get("magnetUri"), Some(RpcValue::String(uri)) if uri.starts_with("magnet:?xt=urn:btih:") )); assert!(matches!( payload.get("announceList"), Some(RpcValue::Array(tiers)) if !tiers.is_empty() )); } other => panic!("unexpected tellStatus after addUri magnet: {other:?}"), } } #[test] fn tracker_announce_ingestion_populates_peers_and_preserves_bt_servers() { fn bencode_int(value: i64) -> Vec { format!("i{value}e").into_bytes() } fn bencode_bytes(value: &[u8]) -> Vec { let mut out = format!("{}:", value.len()).into_bytes(); out.extend_from_slice(value); out } fn bencode_list(values: Vec>) -> Vec { let mut out = vec![b'l']; for value in values { out.extend_from_slice(&value); } out.push(b'e'); out } fn bencode_dict(entries: Vec<(&str, Vec)>) -> Vec { let mut out = vec![b'd']; for (key, value) in entries { out.extend_from_slice(format!("{}:{key}", key.len()).as_bytes()); out.extend_from_slice(&value); } out.push(b'e'); out } let magnet = "magnet:?xt=urn:btih:89abcdef0123456789abcdef0123456789abcdef&dn=bt-peers.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddUri.as_str().to_owned(), params: vec![RpcValue::String(magnet.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addUri magnet result: {other:?}"), }; let announce_bytes = bencode_dict(vec![ ("interval", bencode_int(1800)), ("tracker id", bencode_bytes(b"tracker-session-1")), ( "peers", bencode_list(vec![ bencode_dict(vec![ ("ip", bencode_bytes(b"203.0.113.10")), ("port", bencode_int(51413)), ("peer id", bencode_bytes(b"-AZ2060-123456789012")), ("client", bencode_bytes(b"Azureus 2.0.6.0")), ("choked", bencode_int(0)), ("interested", bencode_int(1)), ]), bencode_dict(vec![ ("ip", bencode_bytes(b"203.0.113.11")), ("port", bencode_int(51414)), ("choked", bencode_int(1)), ("interested", bencode_int(0)), ]), ]), ), ]); let announce = TrackerResponseModel::from_announce_bytes(&announce_bytes) .expect("announce response should parse"); dispatcher .apply_tracker_announce_result(&gid, &announce) .expect("tracker announce should ingest"); let peers = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetPeers.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); match peers.result { Some(RpcValue::Array(entries)) => { assert!(!entries.is_empty(), "expected concrete peer entries"); match entries.first() { Some(RpcValue::Object(peer)) => { assert!(matches!(peer.get("ip"), Some(RpcValue::String(_)))); assert!(matches!(peer.get("port"), Some(RpcValue::String(_)))); } other => panic!("unexpected getPeers entry: {other:?}"), } } other => panic!("unexpected getPeers result: {other:?}"), } let servers = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetServers.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); let error = servers .error .expect("getServers should reject non-active BT downloads"); assert!( error .message .contains(&format!("No active download for GID#{gid}")) ); } #[test] fn live_http_tracker_announce_round_trips_into_dispatcher_peer_visibility() { use std::{ io::{Read, Write}, net::TcpListener, thread, }; let listener = TcpListener::bind("127.0.0.1:0").expect("local listener should bind"); let addr = listener.local_addr().expect("local addr should exist"); let payload = { let mut payload = b"d8:intervali900e5:peers6:".to_vec(); payload.extend_from_slice(&[127, 0, 0, 1, 0x1A, 0xE1]); payload.extend_from_slice(b"e"); payload }; let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().expect("tracker client should connect"); let mut request = [0_u8; 2048]; let read = stream.read(&mut request).expect("request should read"); let request_text = String::from_utf8_lossy(&request[..read]); assert!(request_text.starts_with("GET /announce?")); assert!(request_text.contains("compact=1")); let response = format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n", payload.len() ); stream .write_all(response.as_bytes()) .expect("headers should write"); stream.write_all(&payload).expect("payload should write"); }); let transport = ReqwestTrackerTransport::new().expect("reqwest tracker transport should build"); let announce = transport .announce(&TrackerRequestModel { announce_url: format!("http://{addr}/announce"), info_hash: "0123456789abcdef0123456789abcdef01234567".to_owned(), peer_id: "89abcdef0123456789abcdef0123456789abcdef".to_owned(), port: 6881, uploaded: 0, downloaded: 0, left: 2048, event: Some("started".to_owned()), compact: true, numwant: Some(10), }) .expect("live announce should succeed"); let magnet = "magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=bt-live-tracker.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddUri.as_str().to_owned(), params: vec![RpcValue::String(magnet.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addUri result: {other:?}"), }; dispatcher .apply_tracker_announce_result(&gid, &announce) .expect("tracker announce should ingest"); let peers = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetPeers.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); match peers.result { Some(RpcValue::Array(items)) => match items.first() { Some(RpcValue::Object(peer)) => { assert_eq!( peer.get("ip"), Some(&RpcValue::String("127.0.0.1".to_owned())) ); assert_eq!(peer.get("port"), Some(&RpcValue::String("6881".to_owned()))); } other => panic!("unexpected peer payload after live tracker announce: {other:?}"), }, other => panic!("unexpected getPeers result after live tracker announce: {other:?}"), } handle.join().expect("tracker server thread should join"); } #[test] fn bt_change_option_select_file_round_trips_through_get_files_selected_flags() { let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), params: vec![RpcValue::String(torrent_payload.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addTorrent result: {other:?}"), }; let change = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), params: vec![ RpcValue::String(gid.clone()), RpcValue::Object(BTreeMap::from([( "select-file".to_owned(), RpcValue::String(String::new()), )])), ], meta: Default::default(), }); assert!( change.error.is_some(), "empty select-file value should be rejected to protect BT file selection contract" ); let change = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), params: vec![ RpcValue::String(gid.clone()), RpcValue::Object(BTreeMap::from([( "select-file".to_owned(), RpcValue::String("1".to_owned()), )])), ], meta: Default::default(), }); assert_eq!(change.result, Some(RpcValue::String("OK".to_owned()))); let files = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetFiles.as_str().to_owned(), params: vec![RpcValue::String(gid)], meta: Default::default(), }); match files.result { Some(RpcValue::Array(entries)) => { assert_eq!(entries.len(), 1, "fixture torrent currently has one file"); match entries.first() { Some(RpcValue::Object(file)) => { assert_eq!( file.get("selected"), Some(&RpcValue::String("true".to_owned())) ); } other => panic!("unexpected getFiles entry: {other:?}"), } } other => panic!("unexpected getFiles response: {other:?}"), } } #[test] fn bt_pause_and_selected_state_are_persisted_in_saved_session_file() { let session_root = std::env::temp_dir().join(format!( "aria2-rust-pro-tests-bt-session-{}", std::process::id() )); let _ = std::fs::create_dir_all(&session_root); let session_path = session_root.join("session.txt"); let mut dispatcher = InProcessRpcDispatcher::with_runtime( RuntimeConfig::default().with_session_path(session_path.clone()), ); let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), params: vec![RpcValue::String(torrent_payload.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addTorrent result: {other:?}"), }; let _ = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), params: vec![ RpcValue::String(gid.clone()), RpcValue::Object(BTreeMap::from([( "select-file".to_owned(), RpcValue::String("1".to_owned()), )])), ], meta: Default::default(), }); let pause = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2Pause.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); assert_eq!(pause.result, Some(RpcValue::String(gid.clone()))); let save = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2SaveSession.as_str().to_owned(), params: vec![], meta: Default::default(), }); assert_eq!(save.result, Some(RpcValue::String("OK".to_owned()))); let loaded = load_session_file(&session_path).expect("saved session file should load"); let persisted = loaded .entries .iter() .find(|entry| entry.gid == gid) .expect("saved session should include paused BT gid"); let metadata = persisted .metadata .as_ref() .expect("session entry should include metadata"); assert_eq!(metadata.get("status"), Some(&"paused".to_owned())); assert_eq!( metadata.get("bt.file.0.selected"), Some(&"true".to_owned()), "session metadata should preserve BT selected-file flag for reload path" ); let _ = std::fs::remove_file(session_path); let _ = std::fs::remove_dir_all(session_root); } #[test] fn bt_metadata_only_must_not_report_false_completion_lengths() { let magnet = "magnet:?xt=urn:btih:fedcba98765432100123456789abcdef01234567&dn=bt-metadata-only.iso&tr=http%3A%2F%2Ftracker.example.org%2Fannounce"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddUri.as_str().to_owned(), params: vec![RpcValue::String(magnet.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addUri magnet result: {other:?}"), }; let status = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2TellStatus.as_str().to_owned(), params: vec![RpcValue::String(gid)], meta: Default::default(), }); match status.result { Some(RpcValue::Object(payload)) => { assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); assert_eq!(payload.get("metadataOnly"), Some(&RpcValue::Bool(true))); assert_ne!( payload.get("status"), Some(&RpcValue::String("complete".to_owned())), "metadata-only BT should not look fully complete before payload download" ); assert_eq!( payload.get("completedLength"), Some(&RpcValue::String("0".to_owned())), "metadata-only BT should not fake payload completed length" ); } other => panic!("unexpected tellStatus payload: {other:?}"), } } #[test] fn bt_select_file_pause_resume_and_save_session_contract_is_stable() { let session_root = std::env::temp_dir().join(format!( "aria2-rust-pro-tests-bt-select-pause-resume-{}", std::process::id() )); let _ = std::fs::create_dir_all(&session_root); let session_path = session_root.join("session.txt"); let mut dispatcher = InProcessRpcDispatcher::with_runtime( RuntimeConfig::default().with_session_path(session_path.clone()), ); let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), params: vec![RpcValue::String(torrent_payload.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addTorrent result: {other:?}"), }; let change = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2ChangeOption.as_str().to_owned(), params: vec![ RpcValue::String(gid.clone()), RpcValue::Object(BTreeMap::from([( "select-file".to_owned(), RpcValue::String("1".to_owned()), )])), ], meta: Default::default(), }); assert_eq!(change.result, Some(RpcValue::String("OK".to_owned()))); let pause = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2Pause.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); assert!(pause.error.is_none()); let unpause = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2Unpause.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); assert!(unpause.error.is_none()); let repause = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2Pause.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); assert!(repause.error.is_none()); let files = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2GetFiles.as_str().to_owned(), params: vec![RpcValue::String(gid.clone())], meta: Default::default(), }); match files.result { Some(RpcValue::Array(entries)) => match entries.first() { Some(RpcValue::Object(file)) => assert_eq!( file.get("selected"), Some(&RpcValue::String("true".to_owned())) ), other => panic!("unexpected getFiles entry after pause/resume: {other:?}"), }, other => panic!("unexpected getFiles payload after pause/resume: {other:?}"), } let save = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2SaveSession.as_str().to_owned(), params: vec![], meta: Default::default(), }); assert_eq!(save.result, Some(RpcValue::String("OK".to_owned()))); let loaded = load_session_file(&session_path).expect("saved session file should load"); let entry = loaded .entries .iter() .find(|entry| entry.gid == gid) .expect("saved session should include BT gid"); let metadata = entry .metadata .as_ref() .expect("saved session entry should include metadata"); assert_eq!(metadata.get("status"), Some(&"paused".to_owned())); assert_eq!(metadata.get("bt.file.0.selected"), Some(&"true".to_owned())); let _ = std::fs::remove_file(session_path); let _ = std::fs::remove_dir_all(session_root); } #[test] fn bt_seeding_and_share_visibility_fields_exist_and_are_type_stable() { let torrent_payload = "ZDg6YW5ub3VuY2UzNTpodHRwOi8vdHJhY2tlci5leGFtcGxlLm9yZy9hbm5vdW5jZTQ6aW5mb2Q0Om5hbWUxMDp1YnVudHUuaXNvMTI6cGllY2UgbGVuZ3RoaTE2Mzg0ZTY6bGVuZ3RoaTMyNzY4ZTY6cGllY2VzNDA6YWFhYWFhYWFhYWFhYWFhYWFhYWFiYmJiYmJiYmJiYmJiYmJiYmJiYmVl"; let mut dispatcher = InProcessRpcDispatcher::new(); let add = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2AddTorrent.as_str().to_owned(), params: vec![RpcValue::String(torrent_payload.to_owned())], meta: Default::default(), }); let gid = match add.result { Some(RpcValue::String(gid)) => gid, other => panic!("unexpected addTorrent result: {other:?}"), }; let status = dispatcher.dispatch_json(JsonRpcRequest { jsonrpc: Some("2.0".to_owned()), id: None, method: RpcMethod::Aria2TellStatus.as_str().to_owned(), params: vec![RpcValue::String(gid)], meta: Default::default(), }); match status.result { Some(RpcValue::Object(payload)) => { assert_eq!(payload.get("isBt"), Some(&RpcValue::Bool(true))); assert!( matches!(payload.get("status"), Some(RpcValue::String(_)),), "status field should remain visible for BT" ); assert!( payload.contains_key("seeder"), "BT status should expose seeder visibility field" ); assert!( payload.contains_key("seeders"), "BT status should expose seeders visibility field" ); assert!( payload.contains_key("numSeeders"), "BT status should expose numSeeders visibility field" ); assert!( payload.contains_key("shareRatio"), "BT status should expose shareRatio visibility field" ); assert!( payload.contains_key("shareRatioProgress"), "BT status should expose shareRatioProgress visibility field" ); assert!( payload.contains_key("shareRatioRemaining"), "BT status should expose shareRatioRemaining visibility field" ); assert!( payload.contains_key("shareTime"), "BT status should expose shareTime visibility field" ); if let Some(value) = payload.get("shareRatio") { assert!( matches!(value, RpcValue::String(_) | RpcValue::Number(_)), "shareRatio should stay scalar" ); } if let Some(value) = payload.get("shareTime") { assert!( matches!(value, RpcValue::String(_) | RpcValue::Number(_)), "shareTime should stay scalar" ); } } other => panic!("unexpected tellStatus payload for BT share visibility: {other:?}"), } }