From f7ea7a6ec21f4e1dd8e84ef3222e174b549342ad Mon Sep 17 00:00:00 2001 From: Dylann Batisse Date: Thu, 9 Apr 2026 20:03:45 +0200 Subject: [PATCH] fix(desktop): restore instance settings from backend state --- rustatio-desktop/src/commands/instances.rs | 45 +-- rustatio-desktop/src/main.rs | 15 +- rustatio-desktop/src/persistence.rs | 33 ++ rustatio-desktop/src/state.rs | 105 +++++- rustatio-desktop/src/state_tests.rs | 278 +++++++++++++++ ui/src/lib/instanceStore.js | 385 ++++++++------------- ui/src/lib/utils.js | 95 +++++ ui/src/lib/utils.test.js | 322 +++++++++++++++++ 8 files changed, 981 insertions(+), 297 deletions(-) create mode 100644 rustatio-desktop/src/state_tests.rs create mode 100644 ui/src/lib/utils.test.js diff --git a/rustatio-desktop/src/commands/instances.rs b/rustatio-desktop/src/commands/instances.rs index d6cf887..2577e56 100644 --- a/rustatio-desktop/src/commands/instances.rs +++ b/rustatio-desktop/src/commands/instances.rs @@ -1,7 +1,5 @@ use rustatio_core::validation; -use rustatio_core::{ - FakerConfig, FakerState, RatioFaker, RatioFakerHandle, TorrentInfo, TorrentSummary, -}; +use rustatio_core::{FakerConfig, RatioFaker, RatioFakerHandle, TorrentInfo, TorrentSummary}; use rustatio_watch::InstanceSource; use std::sync::Arc; use tauri::{AppHandle, State}; @@ -25,27 +23,7 @@ pub async fn update_instance_config( config: FakerConfig, state: State<'_, AppState>, ) -> Result<(), String> { - let mut fakers = state.fakers.write().await; - let instance = - fakers.get_mut(&instance_id).ok_or_else(|| format!("Instance {instance_id} not found"))?; - - let stats = instance.faker.stats_snapshot(); - if matches!(stats.state, FakerState::Running | FakerState::Starting | FakerState::Paused) { - return Err("Cannot update config while faker is running".to_string()); - } - - instance.config = config; - - instance - .faker - .update_config(instance.config.clone(), Some(state.http_client.clone())) - .await - .map_err(|e| format!("Failed to update faker config: {e}"))?; - - drop(fakers); - state.refresh_peer_listener_port().await; - - Ok(()) + state.apply_instance_config(instance_id, config).await } #[tauri::command] @@ -102,17 +80,20 @@ pub async fn list_instances(state: State<'_, AppState>) -> Result "manual".to_string(), + InstanceSource::WatchFolder => "watch_folder".to_string(), + }, + tags: instance.tags.clone(), }); } - instances.sort_by_key(|i| i.id); + instances.sort_by_key(|i| i.id.parse::().unwrap_or(0)); Ok(instances) } diff --git a/rustatio-desktop/src/main.rs b/rustatio-desktop/src/main.rs index 28e13ef..9b5172c 100644 --- a/rustatio-desktop/src/main.rs +++ b/rustatio-desktop/src/main.rs @@ -4,6 +4,8 @@ mod commands; mod logging; mod persistence; mod state; +#[cfg(test)] +mod state_tests; mod watch; #[cfg(test)] mod watch_tests; @@ -46,17 +48,8 @@ impl PeerLookup for DesktopPeerLookup { /// Synchronous save for the exit handler (tokio runtime may be winding down) fn save_state_sync(state: &AppState) { - let Ok(handle) = tokio::runtime::Handle::try_current() else { - log::warn!("No tokio runtime available for exit save"); - return; - }; - - let result = tokio::task::block_in_place(|| { - handle.block_on(async move { - let persisted = state.build_persisted_state().await; - persistence::save_state(&persisted) - }) - }); + let persisted = state.build_persisted_state_blocking(); + let result = persistence::save_state(&persisted); match result { Ok(()) => log::info!("Final state saved successfully"), diff --git a/rustatio-desktop/src/persistence.rs b/rustatio-desktop/src/persistence.rs index afc6c3a..c88fc3a 100644 --- a/rustatio-desktop/src/persistence.rs +++ b/rustatio-desktop/src/persistence.rs @@ -3,6 +3,8 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(test)] +use std::sync::{Mutex, OnceLock}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersistedInstance { @@ -74,12 +76,43 @@ impl Default for WatchSettings { } fn state_file_path() -> PathBuf { + if let Some(override_path) = test_state_file_path() { + return override_path; + } + std::env::var("HOME").map_or_else( |_| PathBuf::from("desktop-state.json"), |home| PathBuf::from(home).join(".config").join("rustatio").join("desktop-state.json"), ) } +#[allow(clippy::missing_const_for_fn)] +fn test_state_file_path() -> Option { + #[cfg(test)] + { + let guard = test_state_path_store().lock().ok()?; + guard.clone() + } + + #[cfg(not(test))] + { + None + } +} + +#[cfg(test)] +fn test_state_path_store() -> &'static Mutex> { + static TEST_PATH: OnceLock>> = OnceLock::new(); + TEST_PATH.get_or_init(|| Mutex::new(None)) +} + +#[cfg(test)] +pub fn set_test_state_file_path(path: Option) { + if let Ok(mut guard) = test_state_path_store().lock() { + *guard = path; + } +} + pub fn load_state() -> PersistedState { let path = state_file_path(); diff --git a/rustatio-desktop/src/state.rs b/rustatio-desktop/src/state.rs index 43b6dc6..96b5916 100644 --- a/rustatio-desktop/src/state.rs +++ b/rustatio-desktop/src/state.rs @@ -1,5 +1,5 @@ use rustatio_core::{ - FakerConfig, FakerState, PeerListenerService, PeerListenerStatus, RatioFakerHandle, + FakerConfig, FakerState, FakerStats, PeerListenerService, PeerListenerStatus, RatioFakerHandle, TorrentInfo, TorrentSummary, }; use serde::{Deserialize, Serialize}; @@ -28,10 +28,13 @@ pub struct FakerInstance { #[derive(Clone, Serialize, Deserialize)] pub struct InstanceInfo { - pub id: u32, - pub torrent_name: Option, - pub is_running: bool, - pub is_paused: bool, + pub id: String, + pub torrent: TorrentSummary, + pub config: FakerConfig, + pub stats: FakerStats, + pub created_at: u64, + pub source: String, + pub tags: Vec, } #[derive(Clone)] @@ -152,10 +155,102 @@ impl AppState { } } + pub fn build_persisted_state_blocking(&self) -> PersistedState { + let fakers = self.fakers.blocking_read(); + let next_id = *self.next_instance_id.blocking_read(); + let now = persistence::now_timestamp(); + + let mut instances = HashMap::new(); + for (id, instance) in fakers.iter() { + let stats = instance.faker.stats_snapshot(); + let mut config = instance.config.clone(); + config.completion_percent = stats.torrent_completion; + instances.insert( + *id, + PersistedInstance { + id: *id, + torrent: (*instance.summary).clone(), + config, + cumulative_uploaded: stats.uploaded, + cumulative_downloaded: stats.downloaded, + state: stats.state, + created_at: instance.created_at, + updated_at: now, + tags: instance.tags.clone(), + from_watch_folder: matches!( + instance.source, + rustatio_watch::InstanceSource::WatchFolder + ), + }, + ); + } + + let default_config = self.default_config.blocking_read().clone(); + let watch_settings = self.watch_settings.blocking_read().clone(); + + PersistedState { + instances, + next_instance_id: next_id, + default_config, + watch_settings, + version: 1, + } + } + pub async fn save_state(&self) -> Result<(), String> { let persisted = self.build_persisted_state().await; persistence::save_state(&persisted) } + + pub async fn apply_instance_config( + &self, + instance_id: u32, + config: FakerConfig, + ) -> Result<(), String> { + let old_config = { + let mut fakers = self.fakers.write().await; + let instance = fakers + .get_mut(&instance_id) + .ok_or_else(|| format!("Instance {instance_id} not found"))?; + + let stats = instance.faker.stats_snapshot(); + if matches!( + stats.state, + FakerState::Running | FakerState::Starting | FakerState::Paused + ) { + return Err("Cannot update config while faker is running".to_string()); + } + + let old_config = instance.config.clone(); + instance + .faker + .update_config(config.clone(), Some(self.http_client.clone())) + .await + .map_err(|e| format!("Failed to update faker config: {e}"))?; + instance.config = config.clone(); + old_config + }; + + if let Err(err) = self.save_state().await { + let mut fakers = self.fakers.write().await; + if let Some(instance) = fakers.get_mut(&instance_id) { + instance.config = old_config.clone(); + instance + .faker + .update_config(old_config, Some(self.http_client.clone())) + .await + .map_err(|e| { + format!("{err}; rollback failed while restoring previous config: {e}") + })?; + } + + return Err(err); + } + + self.refresh_peer_listener_port().await; + + Ok(()) + } } pub fn now_secs() -> u64 { diff --git a/rustatio-desktop/src/state_tests.rs b/rustatio-desktop/src/state_tests.rs new file mode 100644 index 0000000..64ecce1 --- /dev/null +++ b/rustatio-desktop/src/state_tests.rs @@ -0,0 +1,278 @@ +#[cfg(test)] +mod tests { + use crate::persistence; + use crate::state::{AppState, FakerInstance, InstanceInfo}; + use rustatio_core::{ + AppConfig, FakerConfig, PeerListenerStatus, RatioFaker, RatioFakerHandle, TorrentInfo, + }; + use rustatio_watch::InstanceSource; + use std::collections::HashMap; + use std::sync::atomic::AtomicBool; + use std::sync::{Arc, OnceLock}; + use tokio::sync::{Mutex, RwLock}; + + fn state_path_test_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn torrent() -> TorrentInfo { + TorrentInfo { + info_hash: [3u8; 20], + announce: "https://tracker.test/announce".to_string(), + announce_list: None, + name: "saved-torrent".to_string(), + total_size: 1024, + piece_length: 256, + num_pieces: 4, + creation_date: None, + comment: None, + created_by: None, + is_single_file: true, + file_count: 1, + files: Vec::new(), + } + } + + fn app_state() -> AppState { + AppState { + fakers: Arc::new(RwLock::new(HashMap::new())), + next_instance_id: Arc::new(RwLock::new(1)), + config: Arc::new(RwLock::new(AppConfig::default())), + http_client: rustatio_core::reqwest::Client::new(), + watch: Arc::new(RwLock::new(None)), + default_config: Arc::new(RwLock::new(None)), + watch_settings: Arc::new(RwLock::new(None)), + should_exit: Arc::new(AtomicBool::new(false)), + close_prompt_open: Arc::new(AtomicBool::new(false)), + peer_listener: Arc::new(RwLock::new(None)), + peer_listener_status: Arc::new(RwLock::new(PeerListenerStatus::default())), + } + } + + async fn insert_instance(state: &AppState, id: u32, config: FakerConfig) { + let info = torrent(); + let torrent = Arc::new(info.clone()); + let summary = Arc::new(info.summary()); + let faker = + RatioFaker::new(Arc::clone(&torrent), config.clone(), Some(state.http_client.clone())); + assert!(faker.is_ok()); + let faker = faker.unwrap_or_else(|_| unreachable!()); + + state.fakers.write().await.insert( + id, + FakerInstance { + faker: Arc::new(RatioFakerHandle::new(faker)), + torrent, + summary, + config, + cumulative_uploaded: 0, + cumulative_downloaded: 0, + tags: vec!["alpha".to_string()], + created_at: 7, + source: InstanceSource::WatchFolder, + }, + ); + } + + #[tokio::test] + async fn build_persisted_state_keeps_instance_config_as_source_of_truth() { + let state = app_state(); + let config = FakerConfig { + upload_rate: 321.0, + port: 51413, + stop_at_ratio: Some(3.5), + ..FakerConfig::default() + }; + + insert_instance(&state, 5, config.clone()).await; + + let persisted = state.build_persisted_state().await; + assert_eq!(persisted.instances.len(), 1); + + let saved = persisted.instances.get(&5); + assert!(saved.is_some()); + let saved = saved.unwrap_or_else(|| unreachable!()); + assert_eq!(saved.config.upload_rate, config.upload_rate); + assert_eq!(saved.config.port, config.port); + assert_eq!(saved.config.stop_at_ratio, config.stop_at_ratio); + assert!(saved.from_watch_folder); + assert_eq!(saved.tags, vec!["alpha".to_string()]); + } + + #[tokio::test] + async fn build_persisted_state_blocking_matches_async_snapshot() { + let state = app_state(); + let config = FakerConfig { + upload_rate: 321.0, + port: 51413, + stop_at_ratio: Some(3.5), + ..FakerConfig::default() + }; + + insert_instance(&state, 5, config).await; + + let async_state = state.build_persisted_state().await; + let blocking_state = tokio::task::spawn_blocking({ + let state = state.clone(); + move || state.build_persisted_state_blocking() + }) + .await; + assert!(blocking_state.is_ok()); + let blocking_state = blocking_state.unwrap_or_else(|_| unreachable!()); + + assert_eq!(blocking_state.next_instance_id, async_state.next_instance_id); + assert_eq!(blocking_state.version, async_state.version); + + let async_default = serde_json::to_value(async_state.default_config); + assert!(async_default.is_ok()); + let async_default = async_default.unwrap_or_else(|_| unreachable!()); + + let blocking_default = serde_json::to_value(blocking_state.default_config); + assert!(blocking_default.is_ok()); + let blocking_default = blocking_default.unwrap_or_else(|_| unreachable!()); + assert_eq!(blocking_default, async_default); + + let async_watch = serde_json::to_value(async_state.watch_settings); + assert!(async_watch.is_ok()); + let async_watch = async_watch.unwrap_or_else(|_| unreachable!()); + + let blocking_watch = serde_json::to_value(blocking_state.watch_settings); + assert!(blocking_watch.is_ok()); + let blocking_watch = blocking_watch.unwrap_or_else(|_| unreachable!()); + assert_eq!(blocking_watch, async_watch); + + let async_instance = async_state.instances.get(&5); + assert!(async_instance.is_some()); + let async_instance = async_instance.unwrap_or_else(|| unreachable!()); + + let blocking_instance = blocking_state.instances.get(&5); + assert!(blocking_instance.is_some()); + let blocking_instance = blocking_instance.unwrap_or_else(|| unreachable!()); + + assert_eq!(blocking_instance.id, async_instance.id); + assert_eq!(blocking_instance.cumulative_uploaded, async_instance.cumulative_uploaded); + assert_eq!(blocking_instance.cumulative_downloaded, async_instance.cumulative_downloaded); + assert_eq!(blocking_instance.created_at, async_instance.created_at); + assert_eq!(blocking_instance.tags, async_instance.tags); + assert_eq!(blocking_instance.from_watch_folder, async_instance.from_watch_folder); + assert!(blocking_instance.updated_at >= async_instance.updated_at); + assert!(blocking_instance.updated_at <= async_instance.updated_at.saturating_add(1)); + + let async_torrent = serde_json::to_value(&async_instance.torrent); + assert!(async_torrent.is_ok()); + let async_torrent = async_torrent.unwrap_or_else(|_| unreachable!()); + + let blocking_torrent = serde_json::to_value(&blocking_instance.torrent); + assert!(blocking_torrent.is_ok()); + let blocking_torrent = blocking_torrent.unwrap_or_else(|_| unreachable!()); + assert_eq!(blocking_torrent, async_torrent); + + let async_config = serde_json::to_value(&async_instance.config); + assert!(async_config.is_ok()); + let async_config = async_config.unwrap_or_else(|_| unreachable!()); + + let blocking_config = serde_json::to_value(&blocking_instance.config); + assert!(blocking_config.is_ok()); + let blocking_config = blocking_config.unwrap_or_else(|_| unreachable!()); + assert_eq!(blocking_config, async_config); + + let async_state_value = serde_json::to_value(async_instance.state); + assert!(async_state_value.is_ok()); + let async_state_value = async_state_value.unwrap_or_else(|_| unreachable!()); + + let blocking_state_value = serde_json::to_value(blocking_instance.state); + assert!(blocking_state_value.is_ok()); + let blocking_state_value = blocking_state_value.unwrap_or_else(|_| unreachable!()); + assert_eq!(blocking_state_value, async_state_value); + } + + #[test] + fn instance_info_serializes_server_style_shape() { + let info = InstanceInfo { + id: "5".to_string(), + torrent: torrent().summary(), + config: FakerConfig::default(), + stats: RatioFaker::stats_from_config(&FakerConfig::default()), + created_at: 7, + source: "watch_folder".to_string(), + tags: vec!["alpha".to_string()], + }; + + let json = serde_json::to_value(info); + assert!(json.is_ok()); + let json = json.unwrap_or_else(|_| unreachable!()); + + assert_eq!(json.get("id").and_then(serde_json::Value::as_str), Some("5")); + assert!(json.get("torrent").is_some()); + assert!(json.get("config").is_some()); + assert!(json.get("stats").is_some()); + assert_eq!(json.get("source").and_then(serde_json::Value::as_str), Some("watch_folder")); + } + + #[tokio::test] + async fn apply_instance_config_saves_updated_state_immediately() { + let _guard = state_path_test_lock().lock().await; + let temp = tempfile::tempdir(); + assert!(temp.is_ok()); + let temp = temp.unwrap_or_else(|_| unreachable!()); + let path = temp.path().join("desktop-state.json"); + persistence::set_test_state_file_path(Some(path)); + + let state = app_state(); + insert_instance(&state, 5, FakerConfig::default()).await; + + let updated = FakerConfig { + upload_rate: 444.0, + download_rate: 222.0, + port: 51413, + stop_at_ratio: Some(4.0), + ..FakerConfig::default() + }; + + let result = state.apply_instance_config(5, updated.clone()).await; + assert!(result.is_ok()); + + let persisted = persistence::load_state(); + let saved = persisted.instances.get(&5); + assert!(saved.is_some()); + let saved = saved.unwrap_or_else(|| unreachable!()); + assert_eq!(saved.config.upload_rate, updated.upload_rate); + assert_eq!(saved.config.port, updated.port); + assert_eq!(saved.config.stop_at_ratio, updated.stop_at_ratio); + + persistence::set_test_state_file_path(None); + } + + #[tokio::test] + async fn apply_instance_config_rolls_back_when_save_fails() { + let _guard = state_path_test_lock().lock().await; + let temp = tempfile::tempdir(); + assert!(temp.is_ok()); + let temp = temp.unwrap_or_else(|_| unreachable!()); + let blocker = temp.path().join("blocked-parent"); + let blocker_write = std::fs::write(&blocker, b"not a directory"); + assert!(blocker_write.is_ok()); + let path = blocker.join("desktop-state.json"); + persistence::set_test_state_file_path(Some(path)); + + let state = app_state(); + let original = FakerConfig { upload_rate: 111.0, port: 40000, ..FakerConfig::default() }; + insert_instance(&state, 5, original.clone()).await; + + let updated = FakerConfig { upload_rate: 444.0, port: 51413, ..FakerConfig::default() }; + + let result = state.apply_instance_config(5, updated).await; + assert!(result.is_err()); + + let fakers = state.fakers.read().await; + let instance = fakers.get(&5); + assert!(instance.is_some()); + let instance = instance.unwrap_or_else(|| unreachable!()); + assert_eq!(instance.config.upload_rate, original.upload_rate); + assert_eq!(instance.config.port, original.port); + assert_eq!(instance.faker.effective_port().await, original.port); + + persistence::set_test_state_file_path(None); + } +} diff --git a/ui/src/lib/instanceStore.js b/ui/src/lib/instanceStore.js index 0dcc28a..cbd8e7e 100644 --- a/ui/src/lib/instanceStore.js +++ b/ui/src/lib/instanceStore.js @@ -3,6 +3,13 @@ import { api } from '$lib/api'; import { getDefaultPreset } from '$lib/defaultPreset.js'; import { getRunMode } from '$lib/api.js'; import { getIdlingStatus, getStatusFromStats } from '$lib/status.js'; +import { + getActiveInstanceIndex, + getBackendInstanceStateFlags, + selectActiveInstanceId, + serializeSessionInstances, + shouldRetryDesktopRestore, +} from '$lib/utils.js'; // Check if running in Tauri const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; @@ -162,87 +169,13 @@ async function saveSession(instances, activeId) { const config = get(globalConfig); if (!config) return; - config.instances = instances.map(inst => ({ - torrent_path: inst.torrentPath || null, - torrent_name: inst.torrent?.name || null, - selected_client: inst.selectedClient, - selected_client_version: inst.selectedClientVersion, - upload_rate: parseFloat(inst.uploadRate), - download_rate: parseFloat(inst.downloadRate), - port: parseInt(inst.port), - vpn_port_sync: !!inst.vpnPortSync, - completion_percent: parseFloat(inst.completionPercent), - initial_uploaded: parseInt(inst.initialUploaded) * 1024 * 1024, - initial_downloaded: parseInt(inst.initialDownloaded) * 1024 * 1024, - cumulative_uploaded: parseInt(inst.cumulativeUploaded) * 1024 * 1024, - cumulative_downloaded: parseInt(inst.cumulativeDownloaded) * 1024 * 1024, - randomize_rates: inst.randomizeRates, - random_range_percent: parseFloat(inst.randomRangePercent), - update_interval_seconds: parseInt(inst.updateIntervalSeconds), - scrape_interval: parseInt(inst.scrapeInterval) || 60, - stop_at_ratio_enabled: inst.stopAtRatioEnabled, - stop_at_ratio: parseFloat(inst.stopAtRatio), - randomize_ratio: inst.randomizeRatio, - random_ratio_range_percent: parseFloat(inst.randomRatioRangePercent), - effective_stop_at_ratio: inst.effectiveStopAtRatio, - stop_at_uploaded_enabled: inst.stopAtUploadedEnabled, - stop_at_uploaded_gb: parseFloat(inst.stopAtUploadedGB), - stop_at_downloaded_enabled: inst.stopAtDownloadedEnabled, - stop_at_downloaded_gb: parseFloat(inst.stopAtDownloadedGB), - stop_at_seed_time_enabled: inst.stopAtSeedTimeEnabled, - stop_at_seed_time_hours: parseFloat(inst.stopAtSeedTimeHours), - idle_when_no_leechers: inst.idleWhenNoLeechers, - idle_when_no_seeders: inst.idleWhenNoSeeders, - post_stop_action: inst.postStopAction, - progressive_rates_enabled: inst.progressiveRatesEnabled, - target_upload_rate: parseFloat(inst.targetUploadRate), - target_download_rate: parseFloat(inst.targetDownloadRate), - progressive_duration_hours: parseFloat(inst.progressiveDurationHours), - })); - - config.active_instance_id = activeId; + config.instances = serializeSessionInstances(instances); + config.active_instance_id = getActiveInstanceIndex(instances, activeId); await api.updateConfig(config); } else { // Web: Save to localStorage const sessionData = { - instances: instances.map(inst => ({ - torrent_path: inst.torrentPath || null, - torrent_name: inst.torrent?.name || null, - torrent_data: inst.torrent || null, // Save the actual torrent object for web - selected_client: inst.selectedClient, - selected_client_version: inst.selectedClientVersion, - upload_rate: parseFloat(inst.uploadRate), - download_rate: parseFloat(inst.downloadRate), - port: parseInt(inst.port), - vpn_port_sync: !!inst.vpnPortSync, - completion_percent: parseFloat(inst.completionPercent), - initial_uploaded: parseInt(inst.initialUploaded) * 1024 * 1024, // Convert MB to bytes - initial_downloaded: parseInt(inst.initialDownloaded) * 1024 * 1024, - cumulative_uploaded: parseInt(inst.cumulativeUploaded) * 1024 * 1024, - cumulative_downloaded: parseInt(inst.cumulativeDownloaded) * 1024 * 1024, - randomize_rates: inst.randomizeRates, - random_range_percent: parseFloat(inst.randomRangePercent), - update_interval_seconds: parseInt(inst.updateIntervalSeconds), - scrape_interval: parseInt(inst.scrapeInterval) || 60, - stop_at_ratio_enabled: inst.stopAtRatioEnabled, - stop_at_ratio: parseFloat(inst.stopAtRatio), - randomize_ratio: inst.randomizeRatio, - random_ratio_range_percent: parseFloat(inst.randomRatioRangePercent), - effective_stop_at_ratio: inst.effectiveStopAtRatio, - stop_at_uploaded_enabled: inst.stopAtUploadedEnabled, - stop_at_uploaded_gb: parseFloat(inst.stopAtUploadedGB), - stop_at_downloaded_enabled: inst.stopAtDownloadedEnabled, - stop_at_downloaded_gb: parseFloat(inst.stopAtDownloadedGB), - stop_at_seed_time_enabled: inst.stopAtSeedTimeEnabled, - stop_at_seed_time_hours: parseFloat(inst.stopAtSeedTimeHours), - idle_when_no_leechers: inst.idleWhenNoLeechers, - idle_when_no_seeders: inst.idleWhenNoSeeders, - post_stop_action: inst.postStopAction, - progressive_rates_enabled: inst.progressiveRatesEnabled, - target_upload_rate: parseFloat(inst.targetUploadRate), - target_download_rate: parseFloat(inst.targetDownloadRate), - progressive_duration_hours: parseFloat(inst.progressiveDurationHours), - })), + instances: serializeSessionInstances(instances), active_instance_id: activeId, }; @@ -271,12 +204,16 @@ function loadSessionFromStorage(config = null) { sessionData = JSON.parse(stored); } - if (!sessionData || !sessionData.instances || sessionData.instances.length === 0) { + const hasInstances = Array.isArray(sessionData.instances) && sessionData.instances.length > 0; + const hasActiveSelection = + sessionData.active_instance_id !== null && sessionData.active_instance_id !== undefined; + + if (!hasInstances && !hasActiveSelection) { return null; } return { - instances: sessionData.instances.map(inst => ({ + instances: (sessionData.instances || []).map(inst => ({ torrentPath: inst.torrent_path, torrentName: inst.torrent_name || null, torrent: inst.torrent_data || null, // Restore torrent data for web @@ -314,8 +251,12 @@ function loadSessionFromStorage(config = null) { targetDownloadRate: inst.target_download_rate, progressiveDurationHours: inst.progressive_duration_hours, })), - activeInstanceId: sessionData.active_instance_id, - activeInstanceIndex: sessionData.active_instance_index, + activeInstanceId: isTauri + ? sessionData.active_instance_real_id + : sessionData.active_instance_id, + activeInstanceIndex: isTauri + ? (sessionData.active_instance_id ?? sessionData.active_instance_index) + : sessionData.active_instance_index, }; } catch (error) { console.error('Failed to load session from storage:', error); @@ -389,6 +330,81 @@ function buildInstanceDefaultsFromServer(serverInst) { }; } +function buildRestoredInstance(serverInst, statusMessage = 'restored from server') { + const instance = createDefaultInstance( + serverInst.id, + buildInstanceDefaultsFromServer(serverInst) + ); + const summary = serverInst.torrent; + instance.torrent = summary; + instance.torrentPath = summary.name; + instance.stats = serverInst.stats; + + const state = serverInst.stats.state; + const flags = getBackendInstanceStateFlags(state); + instance.isRunning = flags.isRunning; + instance.isPaused = flags.isPaused; + + if (instance.isPaused) { + instance.statusMessage = `Paused - ${statusMessage}`; + instance.statusType = 'paused'; + instance.statusIcon = 'pause'; + } else if (flags.isIdling) { + const derived = getStatusFromStats(serverInst.stats); + instance.statusMessage = derived.statusMessage || 'Idling'; + instance.statusType = derived.statusType || 'idle'; + instance.statusIcon = derived.statusIcon || null; + } else if (instance.isRunning) { + instance.statusMessage = `Running - ${statusMessage}`; + instance.statusType = 'running'; + instance.statusIcon = 'rocket'; + } else { + instance.statusMessage = 'Ready to start faking'; + instance.statusType = 'idle'; + instance.statusIcon = null; + } + + return instance; +} + +async function loadDesktopRestoredInstances(config) { + const savedSession = loadSessionFromStorage(config); + const maxAttempts = shouldRetryDesktopRestore(savedSession) ? 10 : 1; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const serverInstances = await api.listInstances(); + if (serverInstances && serverInstances.length > 0) { + return { + restoredInstances: serverInstances.map(serverInst => + buildRestoredInstance(serverInst, 'restored from desktop state') + ), + savedSession, + }; + } + + const summaries = await api.listSummaries(); + if (Array.isArray(summaries) && summaries.length > 0) { + await instanceActions.reconcileWithBackend(); + const currentInstances = get(instances); + if (currentInstances.length > 0) { + return { + restoredInstances: currentInstances, + savedSession, + }; + } + } + + if (attempt < maxAttempts - 1) { + await new Promise(r => setTimeout(r, 300)); + } + } + + return { + restoredInstances: null, + savedSession, + }; +} + // Actions export const instanceActions = { // Initialize - create first instance or restore from storage/server @@ -401,178 +417,38 @@ export const instanceActions = { globalConfig.set(config); } - // For server mode, try to fetch existing instances from backend first. - // Server's listInstances returns full data (config, torrent, stats). - const isServerMode = !isTauri && typeof api.listInstances === 'function'; - if (isServerMode) { + // For server mode and desktop, try to restore from backend first. + if (typeof api.listInstances === 'function') { try { - const serverInstances = await api.listInstances(); - if (serverInstances && serverInstances.length > 0) { - const restoredInstances = serverInstances.map(serverInst => { - // Create frontend instance from server state - const instance = createDefaultInstance( - serverInst.id, - buildInstanceDefaultsFromServer(serverInst) + let savedSession = null; + let restoredInstances = null; + + if (isTauri) { + const restored = await loadDesktopRestoredInstances(config); + restoredInstances = restored.restoredInstances; + savedSession = restored.savedSession; + } else { + const serverInstances = await api.listInstances(); + if (serverInstances && serverInstances.length > 0) { + restoredInstances = serverInstances.map(serverInst => + buildRestoredInstance(serverInst, 'restored from server') ); - - // Set torrent info (server returns summary) - const summary = serverInst.torrent; - instance.torrent = summary; - instance.torrentPath = summary.name; - instance.stats = serverInst.stats; - - // Set running state based on server state - const state = serverInst.stats.state; - instance.isRunning = state === 'Running'; - instance.isPaused = state === 'Paused'; - - if (instance.isRunning) { - instance.statusMessage = 'Running - restored from server'; - instance.statusType = 'running'; - } else if (instance.isPaused) { - instance.statusMessage = 'Paused - restored from server'; - instance.statusType = 'paused'; - } else { - instance.statusMessage = 'Ready to start faking'; - instance.statusType = 'idle'; - } - - return instance; - }); - - instances.set(restoredInstances); - activeInstanceId.set(restoredInstances[0].id); - updateActiveInstanceStore(); - - return restoredInstances[0].id; - } - } catch (error) { - console.warn( - 'Failed to fetch instances from server, falling back to localStorage:', - error - ); - } - } - - // For Tauri desktop, try to restore instances from the backend. - // The backend restores instances from desktop-state.json on startup (async spawn), - // so we may need to retry if the frontend loads before restoration completes. - if (isTauri) { - try { - let summaries = null; - const savedSession = loadSessionFromStorage(config); - const expectInstances = - savedSession && savedSession.instances && savedSession.instances.length > 0; - - const maxRetries = expectInstances ? 5 : 1; - for (let attempt = 0; attempt < maxRetries; attempt++) { - summaries = await api.listSummaries(); - if (summaries && summaries.length > 0) break; - - if (attempt < maxRetries - 1) { - await new Promise(r => setTimeout(r, 300)); } } - if (summaries && summaries.length > 0) { - // Match saved config to backend instances by torrent name - const savedInstances = savedSession?.instances ? [...savedSession.instances] : []; - - const restoredInstances = []; - for (const summary of summaries) { - const instanceId = String(summary.id); - - // Get full torrent data from backend - let torrent = null; - try { - torrent = await api.getInstanceSummary(instanceId); - } catch { - // Instance may have been partially restored - } - - // Match saved config by torrent name for user settings - let savedConfig = null; - if (savedInstances.length > 0) { - const nameMatch = savedInstances.find( - s => s.torrentName && s.torrentName === summary.name - ); - if (nameMatch) { - savedConfig = nameMatch; - savedInstances.splice(savedInstances.indexOf(nameMatch), 1); - } - } - - const defaultPreset = await api.getDefaultPreset(); - const defaults = - savedConfig || (defaultPreset?.settings ?? getDefaultPreset()?.settings ?? {}); - - const instance = createDefaultInstance(instanceId, { - ...defaults, - cumulativeUploaded: bytesToMB(summary.uploaded), - cumulativeDownloaded: bytesToMB(summary.downloaded), - }); - - if (torrent) { - instance.torrent = torrent; - instance.torrentPath = summary.name || torrent.name; - instance.statusMessage = 'Ready to start faking'; - instance.statusType = 'idle'; - } else { - instance.torrentPath = summary.name || ''; - instance.statusMessage = 'Torrent data unavailable'; - instance.statusType = 'warning'; - } - - instance.source = summary.source || 'manual'; - - // Set running state from backend - const state = summary.state?.toLowerCase(); - instance.isRunning = - state === 'running' || - state === 'starting' || - state === 'idle' || - state === 'paused'; - instance.isPaused = state === 'paused'; - - if (instance.isPaused) { - instance.statusMessage = 'Paused'; - instance.statusType = 'paused'; - instance.statusIcon = 'pause'; - } else if (state === 'idle') { - Object.assign(instance, getIdlingStatus()); - } else if (instance.isRunning) { - instance.statusMessage = 'Actively faking ratio...'; - instance.statusType = 'running'; - instance.statusIcon = 'rocket'; - } - - restoredInstances.push(instance); - } + if (restoredInstances && restoredInstances.length > 0) { + instances.set(restoredInstances); + activeInstanceId.set(selectActiveInstanceId(restoredInstances, savedSession)); - if (restoredInstances.length > 0) { - instances.set(restoredInstances); - - const savedActiveId = savedSession?.activeInstanceId; - if (savedActiveId && restoredInstances.some(inst => inst.id === savedActiveId)) { - activeInstanceId.set(savedActiveId); - } else if ( - savedSession?.activeInstanceIndex !== null && - savedSession?.activeInstanceIndex !== undefined && - savedSession.activeInstanceIndex >= 0 && - savedSession.activeInstanceIndex < restoredInstances.length - ) { - activeInstanceId.set(restoredInstances[savedSession.activeInstanceIndex].id); - } else { - activeInstanceId.set(restoredInstances[0].id); - } + updateActiveInstanceStore(); - updateActiveInstanceStore(); - return restoredInstances[0].id; - } + return restoredInstances[0].id; } } catch (error) { console.warn( - 'Failed to restore instances from Tauri backend, falling back to config:', + isTauri + ? 'Failed to restore instances from Tauri backend, falling back to config:' + : 'Failed to fetch instances from server, falling back to localStorage:', error ); } @@ -827,18 +703,29 @@ export const instanceActions = { // Set running state based on server state const state = serverInst.stats.state; - instance.isRunning = state === 'Running'; - instance.isPaused = state === 'Paused'; + const flags = getBackendInstanceStateFlags(state); + instance.isRunning = flags.isRunning; + instance.isPaused = flags.isPaused; - if (instance.isRunning) { - instance.statusMessage = 'Running - added from watch folder'; - instance.statusType = 'running'; - } else if (instance.isPaused) { + if (instance.isPaused) { instance.statusMessage = 'Paused - added from watch folder'; instance.statusType = 'paused'; + instance.statusIcon = 'pause'; + } else if (flags.isIdling) { + const status = serverInst.stats?.is_idling + ? getStatusFromStats(serverInst.stats) + : getIdlingStatus(); + instance.statusMessage = status.statusMessage; + instance.statusType = status.statusType; + instance.statusIcon = status.statusIcon; + } else if (instance.isRunning) { + instance.statusMessage = 'Running - added from watch folder'; + instance.statusType = 'running'; + instance.statusIcon = 'rocket'; } else { instance.statusMessage = 'Ready to start - added from watch folder'; instance.statusType = 'idle'; + instance.statusIcon = null; } // Add to instances store diff --git a/ui/src/lib/utils.js b/ui/src/lib/utils.js index d481118..e7cbfe7 100644 --- a/ui/src/lib/utils.js +++ b/ui/src/lib/utils.js @@ -49,6 +49,101 @@ export function getDownloadType(os) { } } +export function selectActiveInstanceId(restoredInstances, savedSession = null) { + const savedActiveId = savedSession?.activeInstanceId; + if ( + savedActiveId !== null && + savedActiveId !== undefined && + restoredInstances.some(inst => String(inst.id) === String(savedActiveId)) + ) { + return restoredInstances.find(inst => String(inst.id) === String(savedActiveId))?.id ?? null; + } + + if ( + savedSession?.activeInstanceIndex !== null && + savedSession?.activeInstanceIndex !== undefined && + savedSession.activeInstanceIndex >= 0 && + savedSession.activeInstanceIndex < restoredInstances.length + ) { + return restoredInstances[savedSession.activeInstanceIndex].id; + } + + return restoredInstances[0]?.id ?? null; +} + +export function getBackendInstanceStateFlags(state) { + const normalized = String(state || '').toLowerCase(); + + return { + isRunning: + normalized === 'running' || + normalized === 'starting' || + normalized === 'paused' || + normalized === 'idle', + isPaused: normalized === 'paused', + isIdling: normalized === 'idle', + }; +} + +export function serializeSessionInstances(instances) { + return instances.map(inst => ({ + torrent_path: inst.torrentPath || null, + torrent_name: inst.torrent?.name || null, + torrent_data: inst.torrent || null, + selected_client: inst.selectedClient, + selected_client_version: inst.selectedClientVersion, + upload_rate: parseFloat(inst.uploadRate), + download_rate: parseFloat(inst.downloadRate), + port: parseInt(inst.port), + vpn_port_sync: !!inst.vpnPortSync, + completion_percent: parseFloat(inst.completionPercent), + initial_uploaded: parseInt(inst.initialUploaded) * 1024 * 1024, + initial_downloaded: parseInt(inst.initialDownloaded) * 1024 * 1024, + cumulative_uploaded: parseInt(inst.cumulativeUploaded) * 1024 * 1024, + cumulative_downloaded: parseInt(inst.cumulativeDownloaded) * 1024 * 1024, + randomize_rates: inst.randomizeRates, + random_range_percent: parseFloat(inst.randomRangePercent), + update_interval_seconds: parseInt(inst.updateIntervalSeconds), + scrape_interval: parseInt(inst.scrapeInterval) || 60, + stop_at_ratio_enabled: inst.stopAtRatioEnabled, + stop_at_ratio: parseFloat(inst.stopAtRatio), + randomize_ratio: inst.randomizeRatio, + random_ratio_range_percent: parseFloat(inst.randomRatioRangePercent), + effective_stop_at_ratio: inst.effectiveStopAtRatio, + stop_at_uploaded_enabled: inst.stopAtUploadedEnabled, + stop_at_uploaded_gb: parseFloat(inst.stopAtUploadedGB), + stop_at_downloaded_enabled: inst.stopAtDownloadedEnabled, + stop_at_downloaded_gb: parseFloat(inst.stopAtDownloadedGB), + stop_at_seed_time_enabled: inst.stopAtSeedTimeEnabled, + stop_at_seed_time_hours: parseFloat(inst.stopAtSeedTimeHours), + idle_when_no_leechers: inst.idleWhenNoLeechers, + idle_when_no_seeders: inst.idleWhenNoSeeders, + post_stop_action: inst.postStopAction, + progressive_rates_enabled: inst.progressiveRatesEnabled, + target_upload_rate: parseFloat(inst.targetUploadRate), + target_download_rate: parseFloat(inst.targetDownloadRate), + progressive_duration_hours: parseFloat(inst.progressiveDurationHours), + })); +} + +export function getActiveInstanceIndex(instances, activeId) { + const index = instances.findIndex(inst => String(inst.id) === String(activeId)); + return index >= 0 ? index : null; +} + +export function shouldRetryDesktopRestore(savedSession) { + if (!savedSession) { + return false; + } + + return ( + Array.isArray(savedSession.instances) && + savedSession.instances.some( + inst => Boolean(inst?.torrentPath) || Boolean(inst?.torrentName) || Boolean(inst?.torrent) + ) + ); +} + /** * Detects Linux distribution for appropriate package type * @returns {string} Package type (deb, rpm, or AppImage) diff --git a/ui/src/lib/utils.test.js b/ui/src/lib/utils.test.js new file mode 100644 index 0000000..4eaf4ba --- /dev/null +++ b/ui/src/lib/utils.test.js @@ -0,0 +1,322 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + cn, + detectOS, + getActiveInstanceIndex, + getBackendInstanceStateFlags, + getDownloadType, + selectActiveInstanceId, + serializeSessionInstances, + shouldRetryDesktopRestore, +} from './utils.js'; + +function withWindow(windowValue, fn) { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'window'); + + if (windowValue === undefined) { + delete globalThis.window; + } else { + Object.defineProperty(globalThis, 'window', { + configurable: true, + writable: true, + value: windowValue, + }); + } + + try { + fn(); + } finally { + if (descriptor) { + Object.defineProperty(globalThis, 'window', descriptor); + } else { + delete globalThis.window; + } + } +} + +test('cn merges class names and resolves Tailwind conflicts', () => { + assert.equal(cn('px-2', null, 'px-4', 'py-1'), 'px-4 py-1'); +}); + +test('detectOS returns unknown when window is unavailable', () => { + withWindow(undefined, () => { + assert.equal(detectOS(), 'unknown'); + }); +}); + +test('detectOS detects windows from user agent', () => { + withWindow( + { + navigator: { + userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', + platform: 'Win32', + }, + }, + () => { + assert.equal(detectOS(), 'windows'); + } + ); +}); + +test('detectOS detects macos from platform', () => { + withWindow( + { + navigator: { + userAgent: 'Mozilla/5.0', + platform: 'MacIntel', + }, + }, + () => { + assert.equal(detectOS(), 'macos'); + } + ); +}); + +test('detectOS detects linux from user agent', () => { + withWindow( + { + navigator: { + userAgent: 'Mozilla/5.0 (X11; Linux x86_64)', + platform: 'Linux x86_64', + }, + }, + () => { + assert.equal(detectOS(), 'linux'); + } + ); +}); + +test('getDownloadType returns desktop installers for windows and macos', () => { + assert.equal(getDownloadType('windows'), 'msi'); + assert.equal(getDownloadType('macos'), 'dmg'); +}); + +test('getDownloadType returns deb for ubuntu-like linux environments', () => { + withWindow( + { + navigator: { + userAgent: 'Mozilla/5.0 Ubuntu Linux', + platform: 'Linux x86_64', + }, + }, + () => { + assert.equal(getDownloadType('linux'), 'deb'); + } + ); +}); + +test('getDownloadType returns rpm for fedora-like linux environments', () => { + withWindow( + { + navigator: { + userAgent: 'Mozilla/5.0 Fedora Linux', + platform: 'Linux x86_64', + }, + }, + () => { + assert.equal(getDownloadType('linux'), 'rpm'); + } + ); +}); + +test('getDownloadType falls back to deb for generic linux and AppImage for unknown os', () => { + withWindow( + { + navigator: { + userAgent: 'Mozilla/5.0 (X11; Linux x86_64)', + platform: 'Linux x86_64', + }, + }, + () => { + assert.equal(getDownloadType('linux'), 'deb'); + } + ); + + assert.equal(getDownloadType('plan9'), 'AppImage'); +}); + +test('getBackendInstanceStateFlags treats paused and idle as active backend states', () => { + assert.deepEqual(getBackendInstanceStateFlags('Paused'), { + isRunning: true, + isPaused: true, + isIdling: false, + }); + + assert.deepEqual(getBackendInstanceStateFlags('Idle'), { + isRunning: true, + isPaused: false, + isIdling: true, + }); +}); + +test('getBackendInstanceStateFlags treats stopped and unknown states as inactive', () => { + assert.deepEqual(getBackendInstanceStateFlags('Stopped'), { + isRunning: false, + isPaused: false, + isIdling: false, + }); + + assert.deepEqual(getBackendInstanceStateFlags(undefined), { + isRunning: false, + isPaused: false, + isIdling: false, + }); +}); + +test('serializeSessionInstances preserves backup snapshot shape for desktop/web sessions', () => { + const snapshot = serializeSessionInstances([ + { + id: '5', + torrentPath: '/tmp/example.torrent', + torrent: { name: 'example' }, + selectedClient: 'qbittorrent', + selectedClientVersion: '4.6.0', + uploadRate: '123.5', + downloadRate: '45.5', + port: '51413', + vpnPortSync: true, + completionPercent: '87.5', + initialUploaded: '100', + initialDownloaded: '25', + cumulativeUploaded: '300', + cumulativeDownloaded: '50', + randomizeRates: true, + randomRangePercent: '10', + updateIntervalSeconds: '7', + scrapeInterval: '90', + stopAtRatioEnabled: true, + stopAtRatio: '2.5', + randomizeRatio: true, + randomRatioRangePercent: '15', + effectiveStopAtRatio: 2.7, + stopAtUploadedEnabled: true, + stopAtUploadedGB: '3.5', + stopAtDownloadedEnabled: true, + stopAtDownloadedGB: '1.5', + stopAtSeedTimeEnabled: true, + stopAtSeedTimeHours: '12', + idleWhenNoLeechers: true, + idleWhenNoSeeders: false, + postStopAction: 'pause', + progressiveRatesEnabled: true, + targetUploadRate: '500', + targetDownloadRate: '800', + progressiveDurationHours: '6', + }, + ]); + + assert.deepEqual(snapshot, [ + { + torrent_path: '/tmp/example.torrent', + torrent_name: 'example', + torrent_data: { name: 'example' }, + selected_client: 'qbittorrent', + selected_client_version: '4.6.0', + upload_rate: 123.5, + download_rate: 45.5, + port: 51413, + vpn_port_sync: true, + completion_percent: 87.5, + initial_uploaded: 104857600, + initial_downloaded: 26214400, + cumulative_uploaded: 314572800, + cumulative_downloaded: 52428800, + randomize_rates: true, + random_range_percent: 10, + update_interval_seconds: 7, + scrape_interval: 90, + stop_at_ratio_enabled: true, + stop_at_ratio: 2.5, + randomize_ratio: true, + random_ratio_range_percent: 15, + effective_stop_at_ratio: 2.7, + stop_at_uploaded_enabled: true, + stop_at_uploaded_gb: 3.5, + stop_at_downloaded_enabled: true, + stop_at_downloaded_gb: 1.5, + stop_at_seed_time_enabled: true, + stop_at_seed_time_hours: 12, + idle_when_no_leechers: true, + idle_when_no_seeders: false, + post_stop_action: 'pause', + progressive_rates_enabled: true, + target_upload_rate: 500, + target_download_rate: 800, + progressive_duration_hours: 6, + }, + ]); +}); + +test('getActiveInstanceIndex returns null when the active id is missing', () => { + const restored = [{ id: '2' }, { id: '5' }]; + + assert.equal(getActiveInstanceIndex(restored, '99'), null); + assert.equal(getActiveInstanceIndex(restored, '5'), 1); +}); + +test('shouldRetryDesktopRestore only retries when prior desktop state exists', () => { + assert.equal(shouldRetryDesktopRestore(null), false); + assert.equal(shouldRetryDesktopRestore({}), false); + assert.equal(shouldRetryDesktopRestore({ activeInstanceId: null, instances: [] }), false); + assert.equal(shouldRetryDesktopRestore({ activeInstanceId: '5', instances: [] }), false); + assert.equal(shouldRetryDesktopRestore({ activeInstanceId: null, instances: [{}] }), false); + assert.equal( + shouldRetryDesktopRestore({ + activeInstanceId: null, + instances: [{ torrentPath: '/tmp/a.torrent' }], + }), + true + ); + assert.equal( + shouldRetryDesktopRestore({ + activeInstanceId: null, + instances: [{ torrentName: 'ubuntu.iso' }], + }), + true + ); +}); + +test('selectActiveInstanceId prefers saved id when it exists', () => { + const restored = [{ id: '2' }, { id: '5' }]; + + assert.equal(selectActiveInstanceId(restored, { activeInstanceId: '5' }), '5'); +}); + +test('selectActiveInstanceId falls back to saved index for desktop compatibility', () => { + const restored = [{ id: '2' }, { id: '5' }]; + + assert.equal(selectActiveInstanceId(restored, { activeInstanceIndex: 1 }), '5'); +}); + +test('selectActiveInstanceId falls back to first instance when saved selection is missing', () => { + const restored = [{ id: '2' }, { id: '5' }]; + + assert.equal(selectActiveInstanceId(restored, { activeInstanceId: '99' }), '2'); +}); + +test('selectActiveInstanceId prefers saved id over saved index', () => { + const restored = [{ id: '2' }, { id: '5' }]; + + assert.equal( + selectActiveInstanceId(restored, { activeInstanceId: '5', activeInstanceIndex: 0 }), + '5' + ); +}); + +test('selectActiveInstanceId matches ids across number and string types', () => { + const restored = [{ id: 2 }, { id: 5 }]; + + assert.equal(selectActiveInstanceId(restored, { activeInstanceId: '5' }), 5); +}); + +test('selectActiveInstanceId falls back to first instance when saved index is out of range', () => { + const restored = [{ id: '2' }, { id: '5' }]; + + assert.equal(selectActiveInstanceId(restored, { activeInstanceIndex: 9 }), '2'); +}); + +test('selectActiveInstanceId returns null when no instances exist', () => { + assert.equal(selectActiveInstanceId([], { activeInstanceId: '5' }), null); +});