From d875eae15367f1ab44b55d3c26a70810cbdeeb3c Mon Sep 17 00:00:00 2001 From: MatthewMckee4 Date: Sat, 11 Apr 2026 19:48:47 +0100 Subject: [PATCH] test: add unit tests for metadata, cache, project, and logging data paths --- crates/karva_cache/src/cache.rs | 192 +++++++++++++++++++++++++ crates/karva_logging/src/time.rs | 62 ++++++++ crates/karva_metadata/src/options.rs | 191 ++++++++++++++++++++++++ crates/karva_project/src/path/utils.rs | 47 ++++++ 4 files changed, 492 insertions(+) diff --git a/crates/karva_cache/src/cache.rs b/crates/karva_cache/src/cache.rs index 44dacabd..9561d36b 100644 --- a/crates/karva_cache/src/cache.rs +++ b/crates/karva_cache/src/cache.rs @@ -405,4 +405,196 @@ mod tests { assert_eq!(results.stats.total(), 0); assert!(results.diagnostics.is_empty()); } + + #[test] + fn write_last_failed_roundtrips_with_read() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + let failed = vec!["mod::test_a".to_string(), "mod::test_b".to_string()]; + write_last_failed(&cache_dir, &failed).unwrap(); + + let read = read_last_failed(&cache_dir).unwrap(); + assert_eq!(read, failed); + } + + #[test] + fn read_last_failed_missing_file_returns_empty() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + let read = read_last_failed(&cache_dir).unwrap(); + assert!(read.is_empty()); + } + + #[test] + fn write_last_failed_overwrites_previous_list() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + write_last_failed(&cache_dir, &["old".to_string()]).unwrap(); + write_last_failed(&cache_dir, &["new".to_string()]).unwrap(); + + let read = read_last_failed(&cache_dir).unwrap(); + assert_eq!(read, vec!["new".to_string()]); + } + + #[test] + fn write_last_failed_creates_cache_dir() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().join("nested").join("cache")).unwrap(); + assert!(!cache_dir.exists()); + + write_last_failed(&cache_dir, &["x".to_string()]).unwrap(); + + assert!(cache_dir.exists()); + assert_eq!(read_last_failed(&cache_dir).unwrap(), vec!["x".to_string()]); + } + + #[test] + fn read_last_failed_empty_json_list_parses() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + write_last_failed(&cache_dir, &[]).unwrap(); + assert!(read_last_failed(&cache_dir).unwrap().is_empty()); + } + + #[test] + fn prune_cache_keeps_most_recent_run_only() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + for ts in ["run-100", "run-200", "run-300"] { + fs::create_dir_all(tmp.path().join(ts)).unwrap(); + } + + let result = prune_cache(&cache_dir).unwrap(); + assert_eq!(result.removed.len(), 2); + assert!(result.removed.contains(&"run-100".to_string())); + assert!(result.removed.contains(&"run-200".to_string())); + assert!(cache_dir.join("run-300").exists()); + assert!(!cache_dir.join("run-100").exists()); + assert!(!cache_dir.join("run-200").exists()); + } + + #[test] + fn prune_cache_handles_missing_dir() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().join("nope")).unwrap(); + + let result = prune_cache(&cache_dir).unwrap(); + assert!(result.removed.is_empty()); + } + + #[test] + fn prune_cache_ignores_non_run_directories() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + fs::create_dir_all(tmp.path().join("run-10")).unwrap(); + fs::create_dir_all(tmp.path().join("run-20")).unwrap(); + fs::create_dir_all(tmp.path().join("not-a-run")).unwrap(); + fs::write(tmp.path().join("last-failed.json"), "[]").unwrap(); + + prune_cache(&cache_dir).unwrap(); + + assert!(cache_dir.join("not-a-run").exists()); + assert!(cache_dir.join("last-failed.json").exists()); + assert!(cache_dir.join("run-20").exists()); + assert!(!cache_dir.join("run-10").exists()); + } + + #[test] + fn prune_cache_keeps_newest_even_when_names_are_lexicographically_out_of_order() { + // `run-9` lexicographically sorts AFTER `run-100` but numerically it is + // older; pruning must use the numeric `sort_key` or it would delete the + // newest run directory. This test guards against a regression to naive + // string sorting. + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + + fs::create_dir_all(tmp.path().join("run-9")).unwrap(); + fs::create_dir_all(tmp.path().join("run-100")).unwrap(); + + prune_cache(&cache_dir).unwrap(); + + assert!(cache_dir.join("run-100").exists()); + assert!(!cache_dir.join("run-9").exists()); + } + + #[test] + fn clean_cache_removes_dir_and_returns_true() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + fs::create_dir_all(tmp.path().join("run-1")).unwrap(); + + assert!(clean_cache(&cache_dir).unwrap()); + assert!(!cache_dir.exists()); + } + + #[test] + fn clean_cache_missing_dir_returns_false() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().join("nope")).unwrap(); + assert!(!clean_cache(&cache_dir).unwrap()); + } + + #[test] + fn aggregate_results_merges_failed_tests_and_durations_across_workers() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + let run_hash = RunHash::from_existing("run-700"); + + let run_dir = tmp.path().join("run-700"); + let worker0 = run_dir.join("worker-0"); + let worker1 = run_dir.join("worker-1"); + fs::create_dir_all(&worker0).unwrap(); + fs::create_dir_all(&worker1).unwrap(); + + fs::write(worker0.join(FAILED_TESTS_FILE), r#"["mod::test_a"]"#).unwrap(); + fs::write(worker1.join(FAILED_TESTS_FILE), r#"["mod::test_b"]"#).unwrap(); + + let mut d0 = HashMap::new(); + d0.insert("mod::test_a".to_string(), Duration::from_millis(10)); + let mut d1 = HashMap::new(); + d1.insert("mod::test_b".to_string(), Duration::from_millis(20)); + fs::write( + worker0.join(DURATIONS_FILE), + serde_json::to_string(&d0).unwrap(), + ) + .unwrap(); + fs::write( + worker1.join(DURATIONS_FILE), + serde_json::to_string(&d1).unwrap(), + ) + .unwrap(); + + let cache = Cache::new(&cache_dir, &run_hash); + let results = cache.aggregate_results().unwrap(); + + let mut failed = results.failed_tests.clone(); + failed.sort(); + assert_eq!( + failed, + vec!["mod::test_a".to_string(), "mod::test_b".to_string()] + ); + assert_eq!(results.durations.len(), 2); + assert_eq!( + results.durations.get("mod::test_a"), + Some(&Duration::from_millis(10)) + ); + } + + #[test] + fn fail_fast_signal_round_trip() { + let tmp = tempfile::tempdir().unwrap(); + let cache_dir = Utf8PathBuf::try_from(tmp.path().to_path_buf()).unwrap(); + let run_hash = RunHash::from_existing("run-800"); + let cache = Cache::new(&cache_dir, &run_hash); + + assert!(!cache.has_fail_fast_signal()); + cache.write_fail_fast_signal().unwrap(); + assert!(cache.has_fail_fast_signal()); + } } diff --git a/crates/karva_logging/src/time.rs b/crates/karva_logging/src/time.rs index 317117a4..ae8b056d 100644 --- a/crates/karva_logging/src/time.rs +++ b/crates/karva_logging/src/time.rs @@ -12,3 +12,65 @@ pub fn format_duration(duration: Duration) -> String { pub fn format_duration_bracketed(duration: Duration) -> String { format!("[{:>8.3}s]", duration.as_secs_f64()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_zero_is_zero_ms() { + assert_eq!(format_duration(Duration::ZERO), "0ms"); + } + + #[test] + fn format_duration_sub_millisecond_truncates_to_zero_ms() { + assert_eq!(format_duration(Duration::from_micros(500)), "0ms"); + assert_eq!(format_duration(Duration::from_nanos(1)), "0ms"); + } + + #[test] + fn format_duration_exactly_one_ms() { + assert_eq!(format_duration(Duration::from_millis(1)), "1ms"); + } + + #[test] + fn format_duration_sub_two_seconds_uses_milliseconds() { + // The cutoff is `< 2s`, so anything under two full seconds stays in ms, + // including the exact one-second boundary and values like 1999 ms. + assert_eq!(format_duration(Duration::from_millis(1000)), "1000ms"); + assert_eq!(format_duration(Duration::from_millis(1999)), "1999ms"); + } + + #[test] + fn format_duration_two_seconds_switches_to_seconds() { + assert_eq!(format_duration(Duration::from_secs(2)), "2.00s"); + } + + #[test] + fn format_duration_rounds_to_two_decimals() { + assert_eq!(format_duration(Duration::from_millis(2346)), "2.35s"); + assert_eq!(format_duration(Duration::from_millis(2344)), "2.34s"); + } + + #[test] + fn format_duration_minutes_stay_in_seconds() { + assert_eq!(format_duration(Duration::from_secs(125)), "125.00s"); + } + + #[test] + fn format_duration_bracketed_pads_to_width_and_three_decimals() { + assert_eq!(format_duration_bracketed(Duration::ZERO), "[ 0.000s]"); + assert_eq!( + format_duration_bracketed(Duration::from_millis(15)), + "[ 0.015s]" + ); + } + + #[test] + fn format_duration_bracketed_handles_large_values() { + assert_eq!( + format_duration_bracketed(Duration::from_secs(12345)), + "[12345.000s]" + ); + } +} diff --git a/crates/karva_metadata/src/options.rs b/crates/karva_metadata/src/options.rs index 7efd2a00..9fb75325 100644 --- a/crates/karva_metadata/src/options.rs +++ b/crates/karva_metadata/src/options.rs @@ -294,6 +294,197 @@ impl Combine for OutputFormat { } } +#[cfg(test)] +mod tests { + use std::num::NonZeroU32; + + use karva_combine::Combine; + + use super::*; + + #[test] + fn to_settings_defaults_when_empty() { + let settings = TestOptions::default().to_settings(); + assert_eq!(settings.test_function_prefix, "test"); + assert_eq!(settings.max_fail, MaxFail::unlimited()); + assert!(!settings.try_import_fixtures); + assert_eq!(settings.retry, 0); + } + + #[test] + fn to_settings_fail_fast_true_becomes_max_fail_one() { + let options = TestOptions { + fail_fast: Some(true), + ..TestOptions::default() + }; + assert_eq!(options.to_settings().max_fail, MaxFail::from_count(1)); + } + + #[test] + fn to_settings_fail_fast_false_is_unlimited() { + let options = TestOptions { + fail_fast: Some(false), + ..TestOptions::default() + }; + assert_eq!(options.to_settings().max_fail, MaxFail::unlimited()); + } + + #[test] + fn to_settings_max_fail_takes_precedence_over_fail_fast() { + let options = TestOptions { + fail_fast: Some(true), + max_fail: Some(MaxFail::from(NonZeroU32::new(5).expect("non-zero"))), + ..TestOptions::default() + }; + assert_eq!(options.to_settings().max_fail, MaxFail::from_count(5)); + } + + #[test] + fn from_toml_str_parses_nested_sections() { + let toml = r#" +[test] +test-function-prefix = "check" +max-fail = 3 +retry = 2 + +[terminal] +output-format = "concise" +show-python-output = true + +[src] +respect-ignore-files = false +include = ["tests", "more"] +"#; + let options = Options::from_toml_str(toml).expect("parse"); + let settings = options.to_settings(); + assert_eq!(settings.test().test_function_prefix, "check"); + assert_eq!(settings.test().max_fail, MaxFail::from_count(3)); + assert_eq!(settings.test().retry, 2); + assert_eq!(settings.terminal().output_format, OutputFormat::Concise); + assert!(settings.terminal().show_python_output); + assert!(!settings.src().respect_ignore_files); + assert_eq!(settings.src().include_paths, vec!["tests", "more"]); + } + + #[test] + fn from_toml_str_rejects_unknown_key() { + let toml = r" +[test] +fail-fast = true +nonsense = 42 +"; + let err = Options::from_toml_str(toml).expect_err("unknown field"); + assert!(matches!(err, KarvaTomlError::TomlSyntax(_))); + } + + #[test] + fn from_toml_str_rejects_unknown_top_level_section() { + let toml = r" +[bogus] +foo = 1 +"; + assert!(matches!( + Options::from_toml_str(toml), + Err(KarvaTomlError::TomlSyntax(_)) + )); + } + + #[test] + fn from_toml_str_empty_is_default() { + let options = Options::from_toml_str("").expect("parse"); + assert_eq!(options, Options::default()); + } + + #[test] + fn from_toml_str_rejects_max_fail_zero() { + // MaxFail wraps NonZeroU32 so the raw integer 0 must be rejected by the + // deserializer rather than silently producing `unlimited`. + let toml = r" +[test] +max-fail = 0 +"; + assert!(matches!( + Options::from_toml_str(toml), + Err(KarvaTomlError::TomlSyntax(_)) + )); + } + + #[test] + fn combine_prefers_self_for_scalars() { + let cli = TestOptions { + test_function_prefix: Some("cli_prefix".to_string()), + retry: Some(5), + ..TestOptions::default() + }; + let file = TestOptions { + test_function_prefix: Some("file_prefix".to_string()), + retry: Some(1), + try_import_fixtures: Some(true), + ..TestOptions::default() + }; + let merged = cli.combine(file); + assert_eq!(merged.test_function_prefix.as_deref(), Some("cli_prefix")); + assert_eq!(merged.retry, Some(5)); + assert_eq!(merged.try_import_fixtures, Some(true)); + } + + #[test] + fn combine_fills_missing_fields_from_other() { + let cli = TestOptions::default(); + let file = TestOptions { + test_function_prefix: Some("from_file".to_string()), + fail_fast: Some(true), + retry: Some(3), + ..TestOptions::default() + }; + let merged = cli.combine(file); + assert_eq!(merged.test_function_prefix.as_deref(), Some("from_file")); + assert_eq!(merged.fail_fast, Some(true)); + assert_eq!(merged.retry, Some(3)); + } + + #[test] + fn combine_merges_include_paths_with_cli_taking_precedence() { + let cli = SrcOptions { + include: Some(vec!["cli_only".to_string()]), + ..SrcOptions::default() + }; + let file = SrcOptions { + include: Some(vec!["file_only".to_string()]), + respect_ignore_files: Some(false), + }; + let merged = cli.combine(file); + // Vec combine appends `self` after `other`, so CLI entries take precedence at the tail. + let include = merged.include.expect("include set"); + assert_eq!(include, vec!["file_only", "cli_only"]); + assert_eq!(merged.respect_ignore_files, Some(false)); + } + + #[test] + fn project_overrides_apply_cli_over_file() { + let cli_options = Options { + test: Some(TestOptions { + test_function_prefix: Some("cli".to_string()), + ..TestOptions::default() + }), + ..Options::default() + }; + let file_options = Options { + test: Some(TestOptions { + test_function_prefix: Some("file".to_string()), + retry: Some(2), + ..TestOptions::default() + }), + ..Options::default() + }; + let overrides = ProjectOptionsOverrides::new(None, cli_options); + let merged = overrides.apply_to(file_options); + let test = merged.test.expect("test section set"); + assert_eq!(test.test_function_prefix.as_deref(), Some("cli")); + assert_eq!(test.retry, Some(2)); + } +} + #[derive(Debug, Default, PartialEq, Eq, Clone)] pub struct ProjectOptionsOverrides { pub config_file_override: Option, diff --git a/crates/karva_project/src/path/utils.rs b/crates/karva_project/src/path/utils.rs index 6fffcd9f..554f42bf 100644 --- a/crates/karva_project/src/path/utils.rs +++ b/crates/karva_project/src/path/utils.rs @@ -67,4 +67,51 @@ mod tests { let result = absolute("foo/../bar/./baz", "/cwd"); assert_eq!(result, Utf8PathBuf::from("/cwd/bar/baz")); } + + #[test] + fn empty_relative_path_returns_cwd() { + let result = absolute("", "/home/user"); + assert_eq!(result, Utf8PathBuf::from("/home/user")); + } + + #[test] + fn leading_parent_pops_cwd() { + let result = absolute("../../other", "/home/user"); + assert_eq!(result, Utf8PathBuf::from("/other")); + } + + #[test] + fn parent_past_root_stays_at_root() { + // `Utf8PathBuf::pop` on `/` returns false, so extra `..` components + // must not escape the filesystem root. + let result = absolute("../..", "/"); + assert_eq!(result, Utf8PathBuf::from("/")); + } + + #[test] + fn unicode_path_components_are_preserved() { + let result = absolute("カルヴァ/tests", "/home/ユーザー"); + assert_eq!(result, Utf8PathBuf::from("/home/ユーザー/カルヴァ/tests")); + } + + #[test] + fn path_with_spaces_is_preserved() { + let result = absolute("my tests/file.py", "/home/my user"); + assert_eq!(result, Utf8PathBuf::from("/home/my user/my tests/file.py")); + } + + #[test] + fn trailing_slash_on_relative_input_is_normalized() { + // `camino` components strip trailing slashes, so the result should + // match the same input without one. + let with = absolute("foo/bar/", "/cwd"); + let without = absolute("foo/bar", "/cwd"); + assert_eq!(with, without); + } + + #[test] + fn dot_only_path_is_cwd() { + let result = absolute(".", "/home/user"); + assert_eq!(result, Utf8PathBuf::from("/home/user")); + } }