diff --git a/src/app.rs b/src/app.rs index 3df98b4..ff948e1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1147,10 +1147,10 @@ impl Spotter { games.sort_by_cached_key(|(_, g)| std::cmp::Reverse(g.title.to_lowercase())); } SortOrder::PlaytimeDesc => { - games.sort_by(|a, b| b.1.playtime_minutes.cmp(&a.1.playtime_minutes)); + games.sort_by_key(|b| std::cmp::Reverse(b.1.playtime_minutes)); } SortOrder::RatingDesc => { - games.sort_by(|a, b| b.1.rating.unwrap_or(0).cmp(&a.1.rating.unwrap_or(0))); + games.sort_by_key(|b| std::cmp::Reverse(b.1.rating.unwrap_or(0))); } SortOrder::LastPlayedDesc => { games.sort_by(|a, b| b.1.last_played.cmp(&a.1.last_played)); diff --git a/src/handlers/import.rs b/src/handlers/import.rs index cc7cb48..09e4338 100644 --- a/src/handlers/import.rs +++ b/src/handlers/import.rs @@ -71,10 +71,7 @@ impl Spotter { "Importing from Steam... ({} existing games)", self.games.len() ); - eprintln!( - "[app] Starting Steam import (id={})", - &self.profile.steam_id - ); + eprintln!("[app] Starting Steam import (id={})", self.profile.steam_id); let api_key = self.profile.steam_api_key.clone(); let steam_id = self.profile.steam_id.clone(); // Build map of already-enriched games so full_import can skip them diff --git a/src/steam/mod.rs b/src/steam/mod.rs index 0534621..c42cb99 100644 --- a/src/steam/mod.rs +++ b/src/steam/mod.rs @@ -574,97 +574,363 @@ pub fn cover_url(appid: u32) -> String { ) } -// ───── Tests ───── - -#[cfg(test)] -mod tests { - use super::*; +/// Import all Steam games with full details using concurrent HTML scraping. +/// +/// `existing` maps steam_appid → (has_store_data, achievements_100_percent). +/// Games that already have store data are skipped; games with 100% achievements +/// skip the achievement re-fetch. This makes re-imports near-instant when the +/// library is already enriched. +pub fn full_import( + api_key: &str, + steam_id: &str, + existing: HashMap, +) -> Result, String> { + let logger = crate::api_client::ImportLogger::new(&crate::api_client::log_dir()); + logger.log("steam", "Starting Steam full import"); - // ── HTML entity decoding ── + eprintln!("[steam] Starting full import..."); + let games = fetch_owned_games(api_key, steam_id)?; + let total = games.len(); - #[test] - fn decode_entities_basic() { - assert_eq!(decode_html_entities("& < >"), "& < >"); - assert_eq!(decode_html_entities(""hello""), "\"hello\""); - assert_eq!(decode_html_entities("it's"), "it's"); - assert_eq!(decode_html_entities("plain text"), "plain text"); + // Count how many we can skip + let mut skip_scrape = 0usize; + let mut skip_ach = 0usize; + for g in &games { + if let Some(appid) = g.steam_appid { + let (has_store, ach_done) = existing.get(&appid).copied().unwrap_or((false, false)); + if has_store { + skip_scrape += 1; + } + if ach_done { + skip_ach += 1; + } + } } - // ── extract_anchors ── + let need_scrape = total - skip_scrape; + let need_ach = total - skip_ach; + eprintln!( + "[steam] {} games: {} need scraping ({} cached), {} need achievement update ({} complete)", + total, need_scrape, skip_scrape, need_ach, skip_ach + ); - #[test] - fn extract_genre_anchors() { - let html = r#" - Action - RPG - Other - "#; - let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); - assert_eq!(genres, vec!["Action", "RPG"]); - } + let existing = Arc::new(existing); + // Pre-extract appids to avoid locking games just for the ID + let appids: Arc> = + Arc::new(games.iter().map(|g| g.steam_appid.unwrap_or(0)).collect()); + let games = Arc::new(Mutex::new(games)); + let progress = Arc::new(AtomicUsize::new(0)); + let failed = Arc::new(Mutex::new(Vec::::new())); + // AtomicUsize counter replaces Mutex> queue — no lock contention + let next_idx = Arc::new(AtomicUsize::new(0)); - #[test] - fn extract_anchors_empty_html() { - let genres = extract_anchors("", r#"a[href*="/genre/"]"#); - assert!(genres.is_empty()); - } + let mut handles = Vec::new(); - #[test] - fn extract_anchors_no_match() { - let html = r#"Other"#; - let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); - assert!(genres.is_empty()); - } + for _ in 0..SCRAPE_THREADS { + let games = Arc::clone(&games); + let progress = Arc::clone(&progress); + let failed = Arc::clone(&failed); + let next_idx = Arc::clone(&next_idx); + let existing = Arc::clone(&existing); + let appids = Arc::clone(&appids); + let steam_id = steam_id.to_string(); - #[test] - fn extract_anchors_with_nested_tags() { - // scraper collects text recursively — nested elements are handled correctly. - let html = r#"Action"#; - let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); - assert_eq!(genres, vec!["Action"]); - } + let handle = std::thread::spawn(move || { + let agent = make_agent(); - #[test] - fn extract_app_tags() { - let html = r#" - Open World - Survival - "#; - let tags = extract_anchors(html, "a.app_tag"); - assert_eq!(tags, vec!["Open World", "Survival"]); - } + loop { + let i = next_idx.fetch_add(1, Ordering::SeqCst); + if i >= total { + break; + } - // ── extract_div_text ── + let appid = appids[i]; + if appid == 0 { + continue; + } - #[test] - fn extract_description_snippet() { - let html = r#"
A great game about exploration.
"#; - let desc = extract_div_text(html, "game_description_snippet"); - assert_eq!(desc, Some("A great game about exploration.".to_string())); - } + let &(has_store, ach_done) = existing.get(&appid).unwrap_or(&(false, false)); - #[test] - fn extract_div_text_missing_class() { - let html = r#"
text
"#; - assert_eq!(extract_div_text(html, "game_description_snippet"), None); - } + let mut warnings: Vec<&str> = Vec::new(); + let mut skipped_all = true; - #[test] - fn extract_div_text_empty_content() { - let html = r#"
"#; - assert_eq!(extract_div_text(html, "game_description_snippet"), None); - } + // ── Store page scrape (skip if already enriched) ── + if has_store { + // Already have genre + description + release_date → skip + } else { + skipped_all = false; + let mut scrape_ok = false; + for attempt in 0..3u32 { + if attempt > 0 { + let wait = (attempt as u64 + 1) * 2; + std::thread::sleep(std::time::Duration::from_secs(wait)); + } + match scrape_store_page(&agent, appid) { + Ok(data) => { + let mut g = match games.lock() { + Ok(g) => g, + Err(e) => { + eprintln!("[steam] lock poisoned: {}", e); + break; + } + }; + if let Some(url) = data.cover_url { + g[i].cover_url = url; + } + if !data.genre.is_empty() { + g[i].genre = data.genre; + } + if !data.description.is_empty() { + g[i].description = data.description; + } + if !data.release_date.is_empty() { + g[i].release_date = data.release_date; + } + if !data.tags.is_empty() { + g[i].tags = data.tags; + } + g[i].review_percent = data.review_percent; + scrape_ok = true; + break; + } + Err(e) => { + eprintln!( + "[steam] appid {}: scrape attempt {} failed: {}", + appid, + attempt + 1, + e + ); + } + } + } + if !scrape_ok { + warnings.push("store page"); + } + } - // ── extract_meta_content ── + // ── Achievements (skip if 100% complete) ── + if ach_done { + // Already unlocked all achievements → skip + } else { + skipped_all = false; + let mut ach_ok = false; + for attempt in 0..3u32 { + if attempt > 0 { + let wait = 1u64 << attempt; + std::thread::sleep(std::time::Duration::from_secs(wait)); + } + match scrape_achievements(&agent, &steam_id, appid) { + Ok(result) => { + if let Ok(mut g) = games.lock() { + g[i].achievements_unlocked = result.unlocked; + g[i].achievements_total = result.total; + } + if !result.details.is_empty() { + if let Ok(conn) = crate::db::open() { + if let Err(e) = crate::db::save_achievements( + &conn, + appid, + &result.details, + ) { + eprintln!( + "[steam] appid {}: DB save achievements failed: {}", + appid, e + ); + } + } + // Pre-cache achievement icons during import + let icon_count = crate::images::download_achievement_icons( + appid, + &result.details, + ); + if icon_count > 0 { + eprintln!( + "[steam] appid {}: cached {} achievement icons", + appid, icon_count + ); + } + } + ach_ok = true; + break; + } + Err(e) => { + eprintln!( + "[steam] appid {}: achievements attempt {} failed: {}", + appid, + attempt + 1, + e + ); + } + } + } + if !ach_ok { + warnings.push("achievements"); + } + } - #[test] - fn extract_og_image() { - let html = r#""#; - let img = extract_meta_content(html, "og:image"); - assert_eq!(img, Some("https://cdn.example.com/image.jpg".to_string())); - } + // ── Progress ── + let done = progress.fetch_add(1, Ordering::SeqCst) + 1; - #[test] + if skipped_all { + // Don't log every skipped game to avoid spam + } else { + // Read title from locked games only for logging (avoids cloning + // all titles upfront — saves ~150KB+ for large libraries) + let title = games.lock().map(|g| g[i].title.clone()).unwrap_or_default(); + if warnings.is_empty() { + eprintln!("[steam] [{}/{}] {}", done, total, title); + } else { + let msg = format!("'{}': missing {}", title, warnings.join(", ")); + eprintln!("[steam] [{}/{}] WARN: {}", done, total, msg); + if let Ok(mut f) = failed.lock() { + f.push(msg); + } + } + } + + // Only pause between actual HTTP requests + if !skipped_all { + std::thread::sleep(std::time::Duration::from_millis(500)); + } + } + }); + + handles.push(handle); + } + + for h in handles { + h.join().map_err(|_| "Worker thread panicked".to_string())?; + } + + let games = Arc::try_unwrap(games) + .map_err(|_| "Failed to unwrap Arc")? + .into_inner() + .unwrap_or_else(|e| e.into_inner()); + let failed = Arc::try_unwrap(failed) + .map_err(|_| "Failed to unwrap Arc")? + .into_inner() + .unwrap_or_else(|e| e.into_inner()); + + eprintln!( + "[steam] ===== Import complete: {} games processed =====", + total + ); + if failed.is_empty() { + eprintln!("[steam] All games enriched successfully!"); + logger.log( + "steam", + &format!("Import complete: {} games, all enriched", total), + ); + } else { + eprintln!("[steam] {} game(s) with missing data:", failed.len()); + for msg in &failed { + eprintln!("[steam] - {}", msg); + logger.log("steam", &format!("WARN: {}", msg)); + } + logger.log( + "steam", + &format!( + "Import complete: {} games, {} with missing data", + total, + failed.len() + ), + ); + } + + Ok(games) +} + +// ───── Tests ───── + +#[cfg(test)] +mod tests { + use super::*; + + // ── HTML entity decoding ── + + #[test] + fn decode_entities_basic() { + assert_eq!(decode_html_entities("& < >"), "& < >"); + assert_eq!(decode_html_entities(""hello""), "\"hello\""); + assert_eq!(decode_html_entities("it's"), "it's"); + assert_eq!(decode_html_entities("plain text"), "plain text"); + } + + // ── extract_anchors ── + + #[test] + fn extract_genre_anchors() { + let html = r#" + Action + RPG + Other + "#; + let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); + assert_eq!(genres, vec!["Action", "RPG"]); + } + + #[test] + fn extract_anchors_empty_html() { + let genres = extract_anchors("", r#"a[href*="/genre/"]"#); + assert!(genres.is_empty()); + } + + #[test] + fn extract_anchors_no_match() { + let html = r#"Other"#; + let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); + assert!(genres.is_empty()); + } + + #[test] + fn extract_anchors_with_nested_tags() { + // scraper collects text recursively — nested elements are handled correctly. + let html = r#"Action"#; + let genres = extract_anchors(html, r#"a[href*="/genre/"]"#); + assert_eq!(genres, vec!["Action"]); + } + + #[test] + fn extract_app_tags() { + let html = r#" + Open World + Survival + "#; + let tags = extract_anchors(html, "a.app_tag"); + assert_eq!(tags, vec!["Open World", "Survival"]); + } + + // ── extract_div_text ── + + #[test] + fn extract_description_snippet() { + let html = r#"
A great game about exploration.
"#; + let desc = extract_div_text(html, "game_description_snippet"); + assert_eq!(desc, Some("A great game about exploration.".to_string())); + } + + #[test] + fn extract_div_text_missing_class() { + let html = r#"
text
"#; + assert_eq!(extract_div_text(html, "game_description_snippet"), None); + } + + #[test] + fn extract_div_text_empty_content() { + let html = r#"
"#; + assert_eq!(extract_div_text(html, "game_description_snippet"), None); + } + + // ── extract_meta_content ── + + #[test] + fn extract_og_image() { + let html = r#""#; + let img = extract_meta_content(html, "og:image"); + assert_eq!(img, Some("https://cdn.example.com/image.jpg".to_string())); + } + + #[test] fn extract_og_description() { let html = r#""#; let desc = extract_meta_content(html, "og:description"); @@ -932,269 +1198,3 @@ mod tests { assert_eq!(review, Some(95)); } } - -/// Import all Steam games with full details using concurrent HTML scraping. -/// -/// `existing` maps steam_appid → (has_store_data, achievements_100_percent). -/// Games that already have store data are skipped; games with 100% achievements -/// skip the achievement re-fetch. This makes re-imports near-instant when the -/// library is already enriched. -pub fn full_import( - api_key: &str, - steam_id: &str, - existing: HashMap, -) -> Result, String> { - let logger = crate::api_client::ImportLogger::new(&crate::api_client::log_dir()); - logger.log("steam", "Starting Steam full import"); - - eprintln!("[steam] Starting full import..."); - let games = fetch_owned_games(api_key, steam_id)?; - let total = games.len(); - - // Count how many we can skip - let mut skip_scrape = 0usize; - let mut skip_ach = 0usize; - for g in &games { - if let Some(appid) = g.steam_appid { - let (has_store, ach_done) = existing.get(&appid).copied().unwrap_or((false, false)); - if has_store { - skip_scrape += 1; - } - if ach_done { - skip_ach += 1; - } - } - } - - let need_scrape = total - skip_scrape; - let need_ach = total - skip_ach; - eprintln!( - "[steam] {} games: {} need scraping ({} cached), {} need achievement update ({} complete)", - total, need_scrape, skip_scrape, need_ach, skip_ach - ); - - let existing = Arc::new(existing); - // Pre-extract appids to avoid locking games just for the ID - let appids: Arc> = - Arc::new(games.iter().map(|g| g.steam_appid.unwrap_or(0)).collect()); - let games = Arc::new(Mutex::new(games)); - let progress = Arc::new(AtomicUsize::new(0)); - let failed = Arc::new(Mutex::new(Vec::::new())); - // AtomicUsize counter replaces Mutex> queue — no lock contention - let next_idx = Arc::new(AtomicUsize::new(0)); - - let mut handles = Vec::new(); - - for _ in 0..SCRAPE_THREADS { - let games = Arc::clone(&games); - let progress = Arc::clone(&progress); - let failed = Arc::clone(&failed); - let next_idx = Arc::clone(&next_idx); - let existing = Arc::clone(&existing); - let appids = Arc::clone(&appids); - let steam_id = steam_id.to_string(); - - let handle = std::thread::spawn(move || { - let agent = make_agent(); - - loop { - let i = next_idx.fetch_add(1, Ordering::SeqCst); - if i >= total { - break; - } - - let appid = appids[i]; - if appid == 0 { - continue; - } - - let &(has_store, ach_done) = existing.get(&appid).unwrap_or(&(false, false)); - - let mut warnings: Vec<&str> = Vec::new(); - let mut skipped_all = true; - - // ── Store page scrape (skip if already enriched) ── - if has_store { - // Already have genre + description + release_date → skip - } else { - skipped_all = false; - let mut scrape_ok = false; - for attempt in 0..3u32 { - if attempt > 0 { - let wait = (attempt as u64 + 1) * 2; - std::thread::sleep(std::time::Duration::from_secs(wait)); - } - match scrape_store_page(&agent, appid) { - Ok(data) => { - let mut g = match games.lock() { - Ok(g) => g, - Err(e) => { - eprintln!("[steam] lock poisoned: {}", e); - break; - } - }; - if let Some(url) = data.cover_url { - g[i].cover_url = url; - } - if !data.genre.is_empty() { - g[i].genre = data.genre; - } - if !data.description.is_empty() { - g[i].description = data.description; - } - if !data.release_date.is_empty() { - g[i].release_date = data.release_date; - } - if !data.tags.is_empty() { - g[i].tags = data.tags; - } - g[i].review_percent = data.review_percent; - scrape_ok = true; - break; - } - Err(e) => { - eprintln!( - "[steam] appid {}: scrape attempt {} failed: {}", - appid, - attempt + 1, - e - ); - } - } - } - if !scrape_ok { - warnings.push("store page"); - } - } - - // ── Achievements (skip if 100% complete) ── - if ach_done { - // Already unlocked all achievements → skip - } else { - skipped_all = false; - let mut ach_ok = false; - for attempt in 0..3u32 { - if attempt > 0 { - let wait = 1u64 << attempt; - std::thread::sleep(std::time::Duration::from_secs(wait)); - } - match scrape_achievements(&agent, &steam_id, appid) { - Ok(result) => { - if let Ok(mut g) = games.lock() { - g[i].achievements_unlocked = result.unlocked; - g[i].achievements_total = result.total; - } - if !result.details.is_empty() { - if let Ok(conn) = crate::db::open() { - if let Err(e) = crate::db::save_achievements( - &conn, - appid, - &result.details, - ) { - eprintln!( - "[steam] appid {}: DB save achievements failed: {}", - appid, e - ); - } - } - // Pre-cache achievement icons during import - let icon_count = crate::images::download_achievement_icons( - appid, - &result.details, - ); - if icon_count > 0 { - eprintln!( - "[steam] appid {}: cached {} achievement icons", - appid, icon_count - ); - } - } - ach_ok = true; - break; - } - Err(e) => { - eprintln!( - "[steam] appid {}: achievements attempt {} failed: {}", - appid, - attempt + 1, - e - ); - } - } - } - if !ach_ok { - warnings.push("achievements"); - } - } - - // ── Progress ── - let done = progress.fetch_add(1, Ordering::SeqCst) + 1; - - if skipped_all { - // Don't log every skipped game to avoid spam - } else { - // Read title from locked games only for logging (avoids cloning - // all titles upfront — saves ~150KB+ for large libraries) - let title = games.lock().map(|g| g[i].title.clone()).unwrap_or_default(); - if warnings.is_empty() { - eprintln!("[steam] [{}/{}] {}", done, total, title); - } else { - let msg = format!("'{}': missing {}", title, warnings.join(", ")); - eprintln!("[steam] [{}/{}] WARN: {}", done, total, msg); - if let Ok(mut f) = failed.lock() { - f.push(msg); - } - } - } - - // Only pause between actual HTTP requests - if !skipped_all { - std::thread::sleep(std::time::Duration::from_millis(500)); - } - } - }); - - handles.push(handle); - } - - for h in handles { - h.join().map_err(|_| "Worker thread panicked".to_string())?; - } - - let games = Arc::try_unwrap(games) - .map_err(|_| "Failed to unwrap Arc")? - .into_inner() - .unwrap_or_else(|e| e.into_inner()); - let failed = Arc::try_unwrap(failed) - .map_err(|_| "Failed to unwrap Arc")? - .into_inner() - .unwrap_or_else(|e| e.into_inner()); - - eprintln!( - "[steam] ===== Import complete: {} games processed =====", - total - ); - if failed.is_empty() { - eprintln!("[steam] All games enriched successfully!"); - logger.log( - "steam", - &format!("Import complete: {} games, all enriched", total), - ); - } else { - eprintln!("[steam] {} game(s) with missing data:", failed.len()); - for msg in &failed { - eprintln!("[steam] - {}", msg); - logger.log("steam", &format!("WARN: {}", msg)); - } - logger.log( - "steam", - &format!( - "Import complete: {} games, {} with missing data", - total, - failed.len() - ), - ); - } - - Ok(games) -} diff --git a/src/views/stats.rs b/src/views/stats.rs index 9ca2edd..4daeac3 100644 --- a/src/views/stats.rs +++ b/src/views/stats.rs @@ -331,7 +331,7 @@ fn platform_distribution<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Mes fn most_played_games<'a>(app: &'a Spotter, vt: ViewTheme) -> Element<'a, Message> { let mut sorted: Vec<&crate::models::Game> = app.games.iter().collect(); - sorted.sort_by(|a, b| b.playtime_minutes.cmp(&a.playtime_minutes)); + sorted.sort_by_key(|b| std::cmp::Reverse(b.playtime_minutes)); let max_time = sorted.first().map_or(1, |g| g.playtime_minutes) as f32; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 0704e74..7b85f2c 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,9 +1,9 @@ -/// Integration tests for platform import modules. -/// -/// These tests validate JSON deserialization and data-mapping logic -/// using mock API responses, without making real HTTP calls. -/// If a platform changes its API response format, these tests will -/// catch it before it reaches production. +//! Integration tests for platform import modules. +//! +//! These tests validate JSON deserialization and data-mapping logic +//! using mock API responses, without making real HTTP calls. +//! If a platform changes its API response format, these tests will +//! catch it before it reaches production. // ───── Steam mock API tests ───── @@ -160,6 +160,9 @@ mod steam_import { #[serde(default)] games: Vec, } + // Mirrors the upstream payload: the unread fields pin the response + // shape the importer depends on, so they stay despite dead_code. + #[allow(dead_code)] #[derive(serde::Deserialize)] struct GameInfo { appid: u32, @@ -360,6 +363,9 @@ mod psn_import { #[serde(alias = "totalItemCount", default)] total_item_count: u32, } + // Mirrors the upstream payload: the unread fields pin the response + // shape the importer depends on, so they stay despite dead_code. + #[allow(dead_code)] #[derive(serde::Deserialize)] struct PsnTitle { #[serde(alias = "titleId", default)] @@ -496,7 +502,7 @@ mod xbox_import { let games: Vec = history .titles .into_iter() - .filter(|t| is_game(t)) + .filter(is_game) .map(|title| { let last_played = if !title.last_time_played.is_empty() { title.last_time_played.get(..10).unwrap_or("").to_string() @@ -573,6 +579,9 @@ mod xbox_import { #[serde(alias = "continuationToken")] continuation_token: Option, } + // Mirrors the upstream payload: the unread fields pin the response + // shape the importer depends on, so they stay despite dead_code. + #[allow(dead_code)] #[derive(serde::Deserialize)] struct XblTitleSimple { #[serde(alias = "titleId", default)] @@ -627,6 +636,9 @@ mod xbox_import { #[serde(default)] titles: Vec, } + // Mirrors the upstream payload: the unread fields pin the response + // shape the importer depends on, so they stay despite dead_code. + #[allow(dead_code)] #[derive(serde::Deserialize)] struct XblTitle { #[serde(alias = "titleId", default)] @@ -870,6 +882,9 @@ mod gog_import { "totalPages": 3 }"#; + // Mirrors the upstream payload: the unread fields pin the response + // shape the importer depends on, so they stay despite dead_code. + #[allow(dead_code)] #[derive(serde::Deserialize)] struct FilteredResponse { #[serde(default)] @@ -974,6 +989,9 @@ mod epic_import { "AppCategories": ["games"] }"#; + // Mirrors the upstream payload: the unread fields pin the response + // shape the importer depends on, so they stay despite dead_code. + #[allow(dead_code)] #[derive(serde::Deserialize)] struct EpicManifest { #[serde(alias = "DisplayName", default)] @@ -1045,7 +1063,9 @@ mod epic_import { let mut games = Vec::new(); for (_key, val) in obj { - if let (Some(title), Some(app_name)) = ( + // `app_name` is not used in the mapping, but the manifest is only + // treated as a game when the key is present - keep the binding. + if let (Some(title), Some(_app_name)) = ( val.get("title").and_then(|v| v.as_str()), val.get("app_name").and_then(|v| v.as_str()), ) { diff --git a/tests/unit_tests.rs b/tests/unit_tests.rs index 70ade19..8a7eeb4 100644 --- a/tests/unit_tests.rs +++ b/tests/unit_tests.rs @@ -823,8 +823,10 @@ fn game_status_colors_non_black() { #[test] fn settings_favorites_serde_roundtrip() { - let mut settings = spotter::models::Settings::default(); - settings.favorites = vec![1, 42, 100]; + let settings = spotter::models::Settings { + favorites: vec![1, 42, 100], + ..Default::default() + }; let json = serde_json::to_string(&settings).unwrap(); let parsed: spotter::models::Settings = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.favorites, vec![1, 42, 100]);