Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
3623cfc
test: add unit tests for display module helpers
unhappychoice May 3, 2026
fe203bb
test: add unit tests for AchievementCache
unhappychoice May 3, 2026
6dea73d
test: add unit tests for steam models
unhappychoice May 3, 2026
c94addb
test: add unit tests for config module
unhappychoice May 3, 2026
9c09d5a
test: add unit tests for SteamApiError
unhappychoice May 3, 2026
1d5f9ca
test: add unit tests for build_info_lines and account_age_years
unhappychoice May 3, 2026
ce86428
test: add unit tests for image_display helpers
unhappychoice May 3, 2026
d537ecd
test: add unit tests for steam client helpers
unhappychoice May 3, 2026
0aec5cc
test: add unit tests for parse_games_xml edge cases
unhappychoice May 3, 2026
bdce3ea
test: expand account_age_title coverage for all year buckets
unhappychoice May 3, 2026
697196a
test: add unit tests for cache path and AchievementCache fs roundtrip
unhappychoice May 3, 2026
2aa77d8
test: add unit tests for Config env/file precedence and load errors
unhappychoice May 3, 2026
56d1134
test: add unit tests for image_display protocol detection and cursor_…
unhappychoice May 3, 2026
dcd857c
test: add unit tests for image_display cache fs and download fallback
unhappychoice May 3, 2026
867d96a
test: add unit tests for display render helpers and steam status
unhappychoice May 3, 2026
69085f3
test: cover render image-disabled and missing avatar fallback paths
unhappychoice May 3, 2026
e47ba4e
test: add unit tests for get_steam_client_path resolution order
unhappychoice May 3, 2026
884cc32
test: add unit tests for image_display print and rewind paths
unhappychoice May 3, 2026
71aceca
test: cover render_with_image cache-hit path on linux
unhappychoice May 3, 2026
6754e13
test: cover print_image_and_rewind auto block branch
unhappychoice May 3, 2026
718a091
test: cover config load error contexts and percent deserialize errors
unhappychoice May 3, 2026
11ea7af
test: add unit tests for demo_stats and Cli argument parsing
unhappychoice May 3, 2026
ce40bb5
test: cover detect_api_error verbose branch and empty appids fetch
unhappychoice May 3, 2026
39a117b
test: cover download_image network failure and invalid image paths
unhappychoice May 3, 2026
4a3c5d8
test: cover request_with_retry success, error, and retry paths
unhappychoice May 3, 2026
2c92fc7
test: cover download_image success and cache-miss download paths
unhappychoice May 3, 2026
48c734f
test: cover request_with_retry timeout path on hanging server
unhappychoice May 3, 2026
893bc66
test: cover request_with_retry retry-then-success paths
unhappychoice May 3, 2026
23caf60
test: cover create_default_config write failure on directory target
unhappychoice May 3, 2026
aa8dbf6
test: cover NativeSteamClient::try_new none and load-failure branches
unhappychoice May 3, 2026
68e2ee9
test: cover EnvScope and HomeScope Drop restore/remove branches
unhappychoice May 3, 2026
7ef7385
test: cover fetch_achievement_stats empty-games None path
unhappychoice May 3, 2026
1100b8a
test: cover EnvScope Drop restore-previous XDG_CACHE_HOME arm
unhappychoice May 3, 2026
d661ba9
test: cover fetch_achievement_stats cache-hit aggregation and unnamed…
unhappychoice May 3, 2026
cfa803c
test: cover EnvScope Drop remove-xdg-cache-home when prev was None
unhappychoice May 3, 2026
69af3fd
test: cover EnvScope Drop restore-previous arm with pre-set XDG_CACHE…
unhappychoice May 3, 2026
6e1865a
test: cover deserialize_percent SliceRead float, string, and invalid-…
unhappychoice May 3, 2026
8a619dc
test: cover fetch_web_stats Config::load error propagation path
unhappychoice May 3, 2026
46fb4fc
test: cover fetch_stats native-unavailable routing to fetch_web_stats
unhappychoice May 3, 2026
ec1b141
test: cover request_with_retry body-read failure on truncated response
unhappychoice May 3, 2026
87a697f
test: cover load_config_file create_default_config error propagation
unhappychoice May 3, 2026
05232cd
test: cover try_new CreateInterface symbol-missing arm via system lib…
unhappychoice May 3, 2026
93a09fa
test: cover download_image body-read failure on truncated response
unhappychoice May 3, 2026
52f66c5
test: cover query_cell_size_ioctl entry and short-circuit paths under…
unhappychoice May 3, 2026
7f41416
test: cover AchievementCache serde round-trip for NaN and infinite ra…
unhappychoice May 3, 2026
97bc839
test: cover create_default_config no-parent branch via empty PathBuf
unhappychoice May 3, 2026
9f7f33a
test: cover fetch_owned_games_for_appids chunks loop entry with non-e…
unhappychoice May 3, 2026
87e7891
test: cover fetch_achievement_stats rarest tie-break on game then nam…
unhappychoice May 3, 2026
2c8890a
test: share a single env-mutex across modules to keep parallel tests …
unhappychoice May 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
281 changes: 281 additions & 0 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,284 @@ impl AchievementCache {
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|p| p.join("steamfetch").join("achievements.json"))
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_default_cache_is_empty() {
let cache = AchievementCache::default();
assert!(cache.get(123, 0).is_none());
}

#[test]
fn test_set_then_get_returns_entry_when_last_played_matches() {
let mut cache = AchievementCache::default();
cache.set(42, 1000, 5, 10, Some(("Rare", 1.5)));
let entry = cache.get(42, 1000).expect("entry should exist");
assert_eq!(entry.last_played, 1000);
assert_eq!(entry.achieved, 5);
assert_eq!(entry.total, 10);
assert_eq!(entry.rarest_name.as_deref(), Some("Rare"));
assert_eq!(entry.rarest_percent, Some(1.5));
}

#[test]
fn test_get_returns_none_when_last_played_mismatches() {
let mut cache = AchievementCache::default();
cache.set(42, 1000, 5, 10, None);
assert!(cache.get(42, 999).is_none());
}

#[test]
fn test_get_returns_none_for_unknown_appid() {
let mut cache = AchievementCache::default();
cache.set(42, 1000, 5, 10, None);
assert!(cache.get(43, 1000).is_none());
}

#[test]
fn test_set_without_rarest_clears_rarest_fields() {
let mut cache = AchievementCache::default();
cache.set(7, 500, 1, 2, None);
let entry = cache.get(7, 500).unwrap();
assert!(entry.rarest_name.is_none());
assert!(entry.rarest_percent.is_none());
}

#[test]
fn test_set_overwrites_existing_entry() {
let mut cache = AchievementCache::default();
cache.set(1, 100, 1, 5, Some(("Old", 50.0)));
cache.set(1, 200, 3, 5, Some(("New", 10.0)));
assert!(cache.get(1, 100).is_none());
let entry = cache.get(1, 200).unwrap();
assert_eq!(entry.achieved, 3);
assert_eq!(entry.rarest_name.as_deref(), Some("New"));
assert_eq!(entry.rarest_percent, Some(10.0));
}

#[test]
fn test_serde_roundtrip_preserves_entries() {
let mut cache = AchievementCache::default();
cache.set(11, 1234, 8, 12, Some(("Legend", 0.25)));
cache.set(22, 5678, 0, 50, None);
let json = serde_json::to_string(&cache).unwrap();
let restored: AchievementCache = serde_json::from_str(&json).unwrap();
let a = restored.get(11, 1234).unwrap();
assert_eq!(a.achieved, 8);
assert_eq!(a.total, 12);
assert_eq!(a.rarest_name.as_deref(), Some("Legend"));
assert_eq!(a.rarest_percent, Some(0.25));
let b = restored.get(22, 5678).unwrap();
assert_eq!(b.total, 50);
assert!(b.rarest_name.is_none());
}

#[test]
fn test_cache_path_ends_with_steamfetch_achievements_json() {
// `dirs::cache_dir()` can return None on exotic platforms; only
// assert when it exists (mirrors the pattern in image_display tests).
if let Some(path) = cache_path() {
assert!(path.ends_with("steamfetch/achievements.json"));
}
}

#[test]
fn test_serde_roundtrip_handles_nan_percent_as_json_null() {
// serde_json represents non-finite floats (NaN/Inf) as JSON null
// rather than failing. A cache entry containing rarest_percent =
// f64::NAN therefore serializes successfully, and the resulting
// null deserializes back into Option::None — proving that the
// pipeline survives non-finite floats end-to-end without touching
// the filesystem (so this test cannot race on XDG_CACHE_HOME like
// the fs_tests submodule does).
let mut cache = AchievementCache::default();
cache.set(99, 4242, 3, 7, Some(("Edge Float", f64::NAN)));

let json = serde_json::to_string(&cache).expect("NaN should serialize as JSON null");
assert!(
json.contains("\"rarest_percent\":null"),
"NaN should encode as JSON null, got: {json}",
);
assert!(
json.contains("\"rarest_name\":\"Edge Float\""),
"rarest_name should round-trip verbatim, got: {json}",
);

let restored: AchievementCache =
serde_json::from_str(&json).expect("round-trip should deserialize cleanly");
let entry = restored.get(99, 4242).expect("entry must persist");
assert_eq!(entry.achieved, 3);
assert_eq!(entry.total, 7);
assert_eq!(entry.rarest_name.as_deref(), Some("Edge Float"));
assert!(
entry.rarest_percent.is_none(),
"JSON null deserializes Option<f64> as None"
);
}

#[test]
fn test_serde_roundtrip_handles_infinite_percent_as_json_null() {
// f64::INFINITY follows the same JSON-null serialization rule as NaN.
// Verifies the same contract for the other non-finite float so the
// achievement-percent pipeline does not silently drop or panic on
// unusual values returned by the Steam API.
let mut cache = AchievementCache::default();
cache.set(7, 0, 0, 0, Some(("Inf Sentinel", f64::INFINITY)));

let json = serde_json::to_string(&cache).expect("Inf should serialize as JSON null");
assert!(json.contains("\"rarest_percent\":null"));

let restored: AchievementCache = serde_json::from_str(&json).unwrap();
let entry = restored.get(7, 0).expect("entry must persist");
assert_eq!(entry.rarest_name.as_deref(), Some("Inf Sentinel"));
assert!(entry.rarest_percent.is_none());
}

#[cfg(target_os = "linux")]
mod fs_tests {
use super::super::*;
use crate::test_support::lock_env;
use std::env;
use std::time::{SystemTime, UNIX_EPOCH};

fn unique_cache_root(label: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
env::temp_dir().join(format!(
"steamfetch-cache-test-{}-{}-{}",
label,
std::process::id(),
nanos
))
}

struct EnvScope {
prev: Option<String>,
}

impl EnvScope {
fn set(root: &std::path::Path) -> Self {
let prev = env::var("XDG_CACHE_HOME").ok();
env::set_var("XDG_CACHE_HOME", root);
Self { prev }
}
}

impl Drop for EnvScope {
fn drop(&mut self) {
match &self.prev {
Some(v) => env::set_var("XDG_CACHE_HOME", v),
None => env::remove_var("XDG_CACHE_HOME"),
}
}
}

#[test]
fn test_cache_path_uses_xdg_cache_home() {
let _guard = lock_env();
let root = unique_cache_root("xdg");
let _scope = EnvScope::set(&root);

let path = cache_path().expect("XDG_CACHE_HOME set, path must exist");
assert!(path.starts_with(&root));
assert!(path.ends_with("steamfetch/achievements.json"));
}

#[test]
fn test_load_returns_default_when_cache_file_missing() {
let _guard = lock_env();
let root = unique_cache_root("missing");
let _scope = EnvScope::set(&root);
assert!(!root.exists());

let cache = AchievementCache::load();
assert!(cache.get(1, 0).is_none());

let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn test_load_returns_default_when_cache_file_corrupt() {
let _guard = lock_env();
let root = unique_cache_root("corrupt");
let _scope = EnvScope::set(&root);

let path = cache_path().unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "{not valid json").unwrap();

let cache = AchievementCache::load();
assert!(cache.get(1, 0).is_none());

let _ = std::fs::remove_dir_all(&root);
}

#[test]
fn test_envscope_drop_restores_previous_xdg_cache_home() {
// Sibling tests start with XDG_CACHE_HOME unset, so EnvScope::Drop
// only ever runs its `None => env::remove_var(...)` arm. Pre-set
// the variable before constructing an EnvScope so prev = Some(v),
// forcing the `Some(v) => env::set_var(...)` arm on Drop.
let _guard = lock_env();
let outer_prev = env::var("XDG_CACHE_HOME").ok();

let sentinel_root = unique_cache_root("envscope-restore-sentinel");
env::set_var("XDG_CACHE_HOME", &sentinel_root);

let scoped_root = unique_cache_root("envscope-restore-scoped");
{
let _scope = EnvScope::set(&scoped_root);
assert_eq!(
env::var("XDG_CACHE_HOME").unwrap(),
scoped_root.to_string_lossy(),
);
}

// Drop ran the `Some(v) => env::set_var(...)` arm, restoring
// the sentinel value rather than removing the variable.
assert_eq!(
env::var("XDG_CACHE_HOME").unwrap(),
sentinel_root.to_string_lossy(),
);

match outer_prev {
Some(v) => env::set_var("XDG_CACHE_HOME", v),
None => env::remove_var("XDG_CACHE_HOME"),
}
}

#[test]
fn test_save_then_load_roundtrip_persists_entries() {
let _guard = lock_env();
let root = unique_cache_root("rt");
let _scope = EnvScope::set(&root);

let mut cache = AchievementCache::default();
cache.set(101, 5000, 7, 10, Some(("Rare", 0.5)));
cache.set(202, 6000, 0, 5, None);
cache.save();

// The file should exist on disk after save().
let path = cache_path().unwrap();
assert!(path.exists(), "save() must create the cache file");

let loaded = AchievementCache::load();
let a = loaded.get(101, 5000).expect("entry should persist");
assert_eq!(a.achieved, 7);
assert_eq!(a.total, 10);
assert_eq!(a.rarest_name.as_deref(), Some("Rare"));
assert_eq!(a.rarest_percent, Some(0.5));

let b = loaded.get(202, 6000).expect("entry should persist");
assert_eq!(b.total, 5);
assert!(b.rarest_name.is_none());

let _ = std::fs::remove_dir_all(&root);
}
}
}
Loading
Loading