Skip to content

Commit ca451da

Browse files
committed
test: add coverage for WinPython discovery and refresh-state sync (PR #456)
Refactor discover_environments to delegate to discover_environments_in(locator, paths) so tests can inject paths instead of mutating env vars. Add tests for parent-dir and direct-install discovery, dedup contract, missing paths, find report loop, and all sync_refresh_state_from scope variants. Skip the redundant read_dir scan when search path is itself a WinPython install.
1 parent f59a570 commit ca451da

1 file changed

Lines changed: 213 additions & 2 deletions

File tree

  • crates/pet-winpython/src

crates/pet-winpython/src/lib.rs

Lines changed: 213 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,20 @@ fn build_environment(
377377
/// directly so callers always go through the cache.
378378
#[cfg(windows)]
379379
fn discover_environments(locator: &WinPython) -> Vec<PythonEnvironment> {
380+
discover_environments_in(locator, get_winpython_search_paths())
381+
}
382+
383+
/// Testable variant of [`discover_environments`] that takes the list of
384+
/// search paths as input rather than reading environment variables.
385+
#[cfg(any(windows, test))]
386+
fn discover_environments_in(
387+
locator: &WinPython,
388+
search_paths: Vec<PathBuf>,
389+
) -> Vec<PythonEnvironment> {
380390
let mut found: Vec<PythonEnvironment> = Vec::new();
381391
let mut seen_executables: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
382392

383-
for search_path in get_winpython_search_paths() {
393+
for search_path in search_paths {
384394
if !search_path.exists() {
385395
continue;
386396
}
@@ -395,6 +405,7 @@ fn discover_environments(locator: &WinPython) -> Vec<PythonEnvironment> {
395405
.is_some_and(is_winpython_dir_name)
396406
{
397407
collect_install(&search_path, locator, &mut found, &mut seen_executables);
408+
continue;
398409
}
399410

400411
// Otherwise treat it as a directory that may contain WinPython installs.
@@ -419,7 +430,7 @@ fn discover_environments(locator: &WinPython) -> Vec<PythonEnvironment> {
419430
found
420431
}
421432

422-
#[cfg(windows)]
433+
#[cfg(any(windows, test))]
423434
fn collect_install(
424435
winpython_root: &Path,
425436
locator: &WinPython,
@@ -821,4 +832,204 @@ mod tests {
821832
let paths = build_search_paths(Some(home), Some(extra));
822833
assert_eq!(paths, vec![norm_case(default_path)]);
823834
}
835+
836+
/// Build a minimal on-disk WinPython tree under `parent`:
837+
///
838+
/// ```text
839+
/// parent/<name>/
840+
/// .winpython
841+
/// python-3.13.0.amd64/
842+
/// python.exe
843+
/// ```
844+
///
845+
/// Returns the WinPython root and the python.exe path.
846+
#[cfg(windows)]
847+
fn make_winpython_tree(parent: &Path, name: &str) -> (PathBuf, PathBuf) {
848+
let root = parent.join(name);
849+
fs::create_dir_all(&root).unwrap();
850+
File::create(root.join(".winpython")).unwrap();
851+
852+
let python_folder = root.join("python-3.13.0.amd64");
853+
fs::create_dir_all(&python_folder).unwrap();
854+
let python_exe = python_folder.join(if cfg!(windows) {
855+
"python.exe"
856+
} else {
857+
"python"
858+
});
859+
File::create(&python_exe).unwrap();
860+
861+
(root, python_exe)
862+
}
863+
864+
/// `discover_environments_in` should find an install when the search path
865+
/// is a *parent directory* containing one or more WPy* directories.
866+
#[test]
867+
#[cfg(windows)]
868+
fn test_discover_environments_in_parent_dir() {
869+
let dir = tempdir().unwrap();
870+
let (_root, python_exe) = make_winpython_tree(dir.path(), "WPy64-31300");
871+
872+
let locator = WinPython::new();
873+
let envs = discover_environments_in(&locator, vec![dir.path().to_path_buf()]);
874+
875+
assert_eq!(envs.len(), 1, "expected one env, got {envs:?}");
876+
let env = &envs[0];
877+
assert_eq!(env.kind, Some(PythonEnvironmentKind::WinPython));
878+
assert_eq!(
879+
env.executable.as_deref(),
880+
Some(norm_case(&python_exe).as_path())
881+
);
882+
}
883+
884+
/// `discover_environments_in` should also accept a path that *is itself*
885+
/// a WinPython install (the `WINPYTHON_HOME=D:\WPy64-31300` shape).
886+
#[test]
887+
#[cfg(windows)]
888+
fn test_discover_environments_in_direct_install() {
889+
let dir = tempdir().unwrap();
890+
let (root, _python_exe) = make_winpython_tree(dir.path(), "WPy64-31300");
891+
892+
let locator = WinPython::new();
893+
let envs = discover_environments_in(&locator, vec![root]);
894+
895+
assert_eq!(envs.len(), 1);
896+
}
897+
898+
/// Two WPy* directories under the same parent should both be discovered,
899+
/// and we should not double-report when the same install is reachable
900+
/// via two different search paths (dedup by normalized executable).
901+
#[test]
902+
#[cfg(windows)]
903+
fn test_discover_environments_in_dedups() {
904+
let dir = tempdir().unwrap();
905+
let (root_a, _) = make_winpython_tree(dir.path(), "WPy64-31300");
906+
make_winpython_tree(dir.path(), "WPy64-31200");
907+
908+
let locator = WinPython::new();
909+
let envs = discover_environments_in(
910+
&locator,
911+
// Pass parent twice + a direct install to exercise dedup.
912+
vec![dir.path().to_path_buf(), dir.path().to_path_buf(), root_a],
913+
);
914+
915+
assert_eq!(envs.len(), 2, "expected 2 unique envs, got {envs:?}");
916+
}
917+
918+
/// Non-existent search paths are skipped silently.
919+
#[test]
920+
#[cfg(windows)]
921+
fn test_discover_environments_in_ignores_missing_paths() {
922+
let locator = WinPython::new();
923+
let envs =
924+
discover_environments_in(&locator, vec![PathBuf::from(r"Z:\definitely\not\here")]);
925+
assert!(envs.is_empty());
926+
}
927+
928+
/// Drive `find_with_cache`'s cached-hit path and the report loop
929+
/// (the body of `Locator::find` minus the `clear()`).
930+
#[test]
931+
#[cfg(windows)]
932+
fn test_find_with_cache_iteration_reports_each_environment() {
933+
use pet_core::manager::EnvManager;
934+
use pet_core::telemetry::TelemetryEvent;
935+
use std::sync::Mutex;
936+
937+
struct Capture {
938+
envs: Mutex<Vec<PythonEnvironment>>,
939+
}
940+
impl Reporter for Capture {
941+
fn report_manager(&self, _: &EnvManager) {}
942+
fn report_environment(&self, env: &PythonEnvironment) {
943+
self.envs.lock().unwrap().push(env.clone());
944+
}
945+
fn report_telemetry(&self, _: &TelemetryEvent) {}
946+
}
947+
948+
let dir = tempdir().unwrap();
949+
let (root, _) = make_winpython_tree(dir.path(), "WPy64-31300");
950+
951+
let locator = WinPython::new();
952+
let env = PythonEnv::new(
953+
root.join("python-3.13.0.amd64").join("python.exe"),
954+
Some(root.join("python-3.13.0.amd64")),
955+
None,
956+
);
957+
let py_env = locator.try_from(&env).expect("try_from should succeed");
958+
959+
// Drive `find_with_cache` straight to its cached-hit branch and the
960+
// report loop without invoking `discover_environments` (which would
961+
// read real env vars).
962+
locator
963+
.cached_environments
964+
.lock()
965+
.unwrap()
966+
.replace(Arc::new(vec![py_env]));
967+
968+
let reporter = Capture {
969+
envs: Mutex::new(Vec::new()),
970+
};
971+
for env in locator.find_with_cache().iter() {
972+
reporter.report_environment(env);
973+
}
974+
975+
let captured = reporter.envs.lock().unwrap();
976+
assert_eq!(captured.len(), 1);
977+
assert_eq!(captured[0].kind, Some(PythonEnvironmentKind::WinPython));
978+
}
979+
980+
/// `sync_refresh_state_from(Full)` copies the source's cached envs.
981+
#[test]
982+
fn test_sync_refresh_state_full_scope_copies_cache() {
983+
let source = WinPython::new();
984+
source
985+
.cached_environments
986+
.lock()
987+
.unwrap()
988+
.replace(Arc::new(Vec::new()));
989+
990+
let target = WinPython::new();
991+
assert!(target.cached_environments.lock().unwrap().is_none());
992+
993+
target.sync_refresh_state_from(&source, &RefreshStateSyncScope::Full);
994+
assert!(target.cached_environments.lock().unwrap().is_some());
995+
}
996+
997+
/// `sync_refresh_state_from(GlobalFiltered(WinPython))` also syncs.
998+
#[test]
999+
fn test_sync_refresh_state_matching_filtered_scope_copies_cache() {
1000+
let source = WinPython::new();
1001+
source
1002+
.cached_environments
1003+
.lock()
1004+
.unwrap()
1005+
.replace(Arc::new(Vec::new()));
1006+
1007+
let target = WinPython::new();
1008+
target.sync_refresh_state_from(
1009+
&source,
1010+
&RefreshStateSyncScope::GlobalFiltered(PythonEnvironmentKind::WinPython),
1011+
);
1012+
assert!(target.cached_environments.lock().unwrap().is_some());
1013+
}
1014+
1015+
/// `GlobalFiltered` for an unrelated kind and `Workspace` are no-ops.
1016+
#[test]
1017+
fn test_sync_refresh_state_other_scopes_are_no_op() {
1018+
let source = WinPython::new();
1019+
source
1020+
.cached_environments
1021+
.lock()
1022+
.unwrap()
1023+
.replace(Arc::new(Vec::new()));
1024+
1025+
let target = WinPython::new();
1026+
target.sync_refresh_state_from(
1027+
&source,
1028+
&RefreshStateSyncScope::GlobalFiltered(PythonEnvironmentKind::Conda),
1029+
);
1030+
assert!(target.cached_environments.lock().unwrap().is_none());
1031+
1032+
target.sync_refresh_state_from(&source, &RefreshStateSyncScope::Workspace);
1033+
assert!(target.cached_environments.lock().unwrap().is_none());
1034+
}
8241035
}

0 commit comments

Comments
 (0)